Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Drag and drop for the programs in harrier's panes, with kitty's protocol//! (OSC 72): dropping onto a program that accepts drops, and dragging out//! of one that offers them. libghostty-vt speaks the protocol; this//! connects it to the window system.//!//! ## Drops//!//! While a program accepts drops, something dragged over its pane — from//! another program, or from another pane — goes to it: where the pointer//! is, the types on offer and the actions allowed. It answers whether it//! would take it, and as what, and only once it has does the window accept//! the drag, so that a drop it would not take is refused rather than//! dropped on the floor. Dropped, it asks for the data a type at a time,//! which is read from the window system down a pipe, or from a file when a//! program on another machine wants the files the drop names.//!//! With no program accepting drops in the pane, a drop is pasted into//! it instead, as a terminal without the protocol would: the paths of//! dropped files, quoted for a shell and separated by spaces, or dropped//! text as it is. Either is taken as a copy, never a move, and nothing is//! pasted with a newline at the end, so a drop never runs anything.//!//! ## Drags//!//! While a program offers drags, a press dragged more than a cell's width//! over its pane asks it to start one. It says what it offers, with the//! data for some types sent ahead, and the window starts a drag with the//! pointer that is down. A drop target that wants a type the program did//! not send ahead gets it from the program as it arrives.//!//! ## Waiting//!//! Nothing here blocks: reads and writes go through non-blocking pipes the//! loop waits on beside everything else, a chunk at a time. A drop from//! one pane to another is this process at both ends of the same pipe.//!//! On Windows, a drop's data is all taken from the window system as it is//! dropped, so it is read from memory instead, still a chunk at a time,//! and a file a program on another machine wants is read the same way.//! What a program drags is handed to the window system as a drop target//! asks for it, there being no pipe.//!//! ## Programs on other machines//!//! A program says which machine it is on, and one on another — over ssh —//! cannot open the files a drop names, so it is sent them, file by file;//! and the files it drags arrive here the same way, are written under a//! directory of harrier's own, and the drop target is given a list naming//! those copies. The copies stay until the next drag starts, or the//! program stops offering drags, since a drop target may still be reading//! them after the drag is over.//!//! On Windows the copies are under `%LOCALAPPDATA%\harrier\drags`, each//! name made one Windows can hold — what it cannot is replaced with `_` —//! and a symbolic link is not copied, since making one takes a privilege//! the user may not have.//!//! Panes are named by their IDs rather than held, since a pane can close,//! or move to another window, while a drag is under way.
const std = @import("std");const builtin = @import("builtin");const Allocator = std.mem.Allocator;const linux = std.os.linux;const Handle = @import("os/handle.zig").Handle;
const App = @import("App.zig");const Window = @import("Window.zig");const Pane = @import("Pane.zig");const Session = @import("termio/Session.zig");const ev = @import("aerie").platform.event;
const dnd = Session.dnd;
const Dnd = @This();
const is_linux = builtin.os.tag == .linux;
const log = std.log.scoped(.dnd);
app: *App,/// Something dragged over a window, from when it enters until it leaves/// or its drop is concluded.drop: ?Drop = null,/// A drag a program started, until it is over.drag: ?Drag = null,/// Drop data being read for a program, a request at a time.read: ?Read = null,/// Data being written to drop targets.writes: std.ArrayList(Write) = .empty,/// Where the files of the last drag from another machine were written.remote_dir: ?[:0]u8 = null,
const Drop = struct { /// The pane the pointer is over, if its program accepts drops. pane: ?u64 = null, /// The types on offer, owned by the platform until the drop is over. mimes: []const []const u8 = &.{}, actions: ev.DndActions = .{}, /// Where, in physical pixels in the window. x: f32 = 0, y: f32 = 0, dropped: bool = false, /// The program asked for files, being on another machine. sent_files: bool = false, /// The pane the pointer is over when its program accepts no drops, /// to paste the drop into, and the type it would be pasted from. paste: ?u64 = null, paste_mime: []const u8 = "",};
const Drag = struct { pane: u64, arena: std.heap.ArenaAllocator, items: []const dnd.DragEvent.Offer.Item, /// From a program on another machine: its files come to `Remote`. remote: ?Remote = null,};
/// The files of a drag from another machine, and the list naming them.const Remote = struct { /// The directory they are written under, open. dir: if (is_linux) linux.fd_t else std.Io.Dir, /// Which item is the text/uri-list. uri_index: ?u32, /// What each entry of the list was written as, relative to `dir`, in /// the arena. entries: std.AutoHashMapUnmanaged(u32, []const u8) = .empty, /// The file being written, and its path. file: ?(if (is_linux) linux.fd_t else std.Io.File) = null, /// A symbolic link's target, until all of it has come. link: std.ArrayList(u8) = .empty, /// The list, rewritten to name the copies, once it has come: it is /// fetched only once, so it is kept for every target that asks. list: ?[]const u8 = null, /// On Windows, the list as it comes, until all of it has. incoming: std.ArrayList(u8) = .empty,};
const Read = struct { pane: u64, id: u32, from: From, /// Read to be pasted rather than for a program: all of it, here. paste: ?std.ArrayList(u8) = null,};
/// Where a read's data comes from: a pipe or a file on Linux; on Windows,/// a copy of the drop's data and how much of it has been read, or a file.const From = if (is_linux) linux.fd_t else union(enum) { memory: struct { bytes: []u8, at: usize = 0, }, file: std.Io.File,};
/// The most of a drop that is pasted.const max_paste = 16 * 1024 * 1024;
/// What a drop is pasted from, best first: the files it names, then text.const paste_mimes = [_][]const u8{ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "TEXT", "STRING" };
const Write = struct { /// The pane whose program provides the data, and which of its drag's /// items. pane: u64, index: u32, fd: linux.fd_t, data: std.ArrayList(u8) = .empty, sent: usize = 0, /// All of the data is in `data`. complete: bool = false, /// The list of a drag from another machine: held until it is all /// here, then rewritten to name the copies. rewrite: bool = false,};
/// How much of a drop to read for a program on each turn of the loop, so/// that a large one does not hold up the rest.const read_chunk = 64 * 1024;const reads_per_turn = 4;
pub fn deinit(d: *Dnd) void { d.endRead(); for (d.writes.items) |*wr| closeWrite(d.app.gpa, wr); d.writes.deinit(d.app.gpa); if (d.drag) |*g| d.endDrag(g); d.drag = null; d.removeCopies();}
// ---------------------------------------------------------------------------// What the programs say// ---------------------------------------------------------------------------
/// Acts on the drag and drop events `p`'s program caused.pub fn handle(d: *Dnd, w: *Window, p: *Pane, events: []const Session.Dnd) void { for (events) |e| switch (e) { .drop => |de| d.dropEvent(w, p, de), .drag => |de| d.dragEvent(w, p, de), };}
fn dropEvent(d: *Dnd, w: *Window, p: *Pane, e: dnd.DropEvent) void { switch (e) { .registration => |r| { p.dnd_accepting = r.accepting; if (!r.accepting) if (d.drop) |*drop| if (drop.pane == p.id and !drop.dropped) { drop.pane = null; w.pw.dropAccept(null, .none); }; }, .acceptance => |a| { const drop = &(d.drop orelse return); if (drop.pane != p.id or drop.dropped) return; // The type it would most like that is on offer; it may not // have said, and then it gets what it asks for later. var mime: ?[]const u8 = null; var it = a.mimes.iterator(); while (it.next()) |m| if (contains(drop.mimes, m)) { mime = m; break; }; if (mime == null and drop.mimes.len > 0) mime = drop.mimes[0]; w.pw.dropAccept(mime, switch (a.operation) { .none => .none, .copy => .copy, .move => .move, }); }, .data_request => |r| d.serve(w, p, r), .concluded => |op| { const drop = &(d.drop orelse return); // A conclusion for a drag that never dropped is the program // giving up a drop that a new drag replaced; nothing to do. if (drop.pane != p.id or !drop.dropped) return; d.endRead(); w.pw.dropFinish(switch (op) { .none => .none, .copy => .copy, .move => .move, }); d.drop = null; }, }}
fn dragEvent(d: *Dnd, w: *Window, p: *Pane, e: dnd.DragEvent) void { switch (e) { .offers => |on| { p.dnd_offers = on; if (!on) if (d.drag) |g| if (g.pane == p.id) d.removeCopies(); }, .start => |offer| d.startDrag(w, p, offer), .data => |data| d.dragData(p, data), .cancel => if (d.drag) |g| if (g.pane == p.id) { w.pw.cancelDrag(); d.removeCopies(); }, // The drag image is set as the drag starts and kept. .image => {}, // Only a program on another machine sends files, and one that // says it is on this machine is believed. .remote_file => |f| d.remoteFile(p, f), }}
// ---------------------------------------------------------------------------// Drops// ---------------------------------------------------------------------------
/// Something is dragged over `w`, at `m`'s point in logical pixels.pub fn dropMotion(d: *Dnd, w: *Window, m: ev.DropMotion) void { if (d.drop == null) d.drop = .{}; const drop = &d.drop.?; drop.mimes = m.mimes; drop.actions = m.actions; drop.x = m.x * w.scale; drop.y = m.y * w.scale;
const under = w.tab().paneAt(drop.x, drop.y); const target: ?*Pane = if (under) |p| if (p.dnd_accepting) p else null else null; const target_id: ?u64 = if (target) |p| p.id else null; if (drop.pane != null and drop.pane != target_id) { if (d.app.findPane(drop.pane.?)) |old| _ = d.dropInputTo(old.window, old.pane, .leave); w.pw.dropAccept(null, .none); } drop.pane = target_id; drop.paste = null; const p = target orelse { // Nothing to take it but a paste: as a copy, of a type that pastes. const mime = pasteMime(drop.mimes); if (under == null or mime == null or !drop.actions.copy) return w.pw.dropAccept(null, .none); drop.paste = under.?.id; drop.paste_mime = mime.?; return w.pw.dropAccept(mime, .copy); }; // The answer comes back as an acceptance. _ = d.dropInputTo(w, p, .{ .move = d.motion(w, p) });}
/// What was dragged over `w` has gone, or was dropped without being/// accepted.pub fn dropLeave(d: *Dnd, _: *Window) void { const drop = d.drop orelse return; d.drop = null; if (drop.pane) |id| if (d.app.findPane(id)) |found| { _ = d.dropInputTo(found.window, found.pane, .leave); };}
/// It was dropped on `w`, accepted.pub fn dropped(d: *Dnd, w: *Window, m: ev.DropMotion) void { const drop = &(d.drop orelse return w.pw.dropFinish(.none)); drop.mimes = m.mimes; drop.dropped = true; if (drop.pane == null) if (drop.paste) |id| { const from = d.receive(w, drop.paste_mime) catch |err| { log.info("reading a drop to paste: {t}", .{err}); d.drop = null; return w.pw.dropFinish(.none); }; d.endRead(); d.read = .{ .pane = id, .id = 0, .from = from, .paste = .empty }; return; }; const found = (if (drop.pane) |id| d.app.findPane(id) else null) orelse { d.drop = null; return w.pw.dropFinish(.none); }; _ = d.dropInputTo(w, found.pane, .{ .drop = d.motion(w, found.pane) });}
fn pasteMime(mimes: []const []const u8) ?[]const u8 { for (paste_mimes) |want| if (contains(mimes, want)) return want; return null;}
/// The drop read for pasting is all here: pasted into the pane it was/// dropped on, and the drop done.fn pasteDrop(d: *Dnd, r: *Read) void { const data = r.paste.?.items; const drop = d.drop orelse return; d.drop = null; const found = d.app.findPane(r.pane) orelse return; const gpa = d.app.gpa; const text = if (std.mem.eql(u8, drop.paste_mime, "text/uri-list")) pathsOf(gpa, data, .native) catch null else gpa.dupe(u8, data) catch null; if (text) |t| { defer gpa.free(t); if (t.len > 0) found.window.pasteInto(found.pane, t, .drop); } found.window.pw.dropFinish(if (text != null) .copy else .none);}
/// How paths are written and quoted: for a POSIX shell, or for Windows,/// where the shell may be cmd or PowerShell.const PathStyle = enum { posix, windows,
const native: PathStyle = if (builtin.os.tag == .windows) .windows else .posix;};
/// A text/uri-list as a shell would want it typed: the paths of local/// files, other URIs as they are, each quoted if it needs to be, with a/// space between them. On Windows a file on another machine is named by/// its UNC path.fn pathsOf(gpa: Allocator, list: []const u8, style: PathStyle) Allocator.Error![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); var lines = std.mem.splitScalar(u8, list, '\n'); var first = true; while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0 or line[0] == '#') continue; var path_buf: std.ArrayList(u8) = .empty; defer path_buf.deinit(gpa); const item = switch (style) { .posix => if (localPath(line)) |encoded| path: { try percentDecode(gpa, &path_buf, encoded); break :path path_buf.items; } else line, .windows => if (windowsPath(line)) |encoded| path: { if (encoded.host.len > 0) try path_buf.print(gpa, "//{s}", .{encoded.host}); try percentDecode(gpa, &path_buf, encoded.path); std.mem.replaceScalar(u8, path_buf.items, '/', '\\'); break :path path_buf.items; } else line, }; if (!first) out.writer.writeByte(' ') catch return error.OutOfMemory; first = false; (switch (style) { .posix => shellQuote(&out.writer, item), .windows => windowsQuote(&out.writer, item), }) catch return error.OutOfMemory; } return out.toOwnedSlice();}
/// The path of a `file:` URI naming a file on this machine, still/// percent-encoded.fn localPath(uri: []const u8) ?[]const u8 { const prefix = "file://"; if (!std.ascii.startsWithIgnoreCase(uri, prefix)) return null; const rest = uri[prefix.len..]; const slash = std.mem.findScalar(u8, rest, '/') orelse return null; const host = rest[0..slash]; if (host.len != 0 and !std.ascii.eqlIgnoreCase(host, "localhost")) return null; return rest[slash..];}
/// A `file:` URI's host, empty for this machine, and its path, still/// percent-encoded, without the slash before a drive letter.fn windowsPath(uri: []const u8) ?struct { host: []const u8, path: []const u8 } { const prefix = "file://"; if (!std.ascii.startsWithIgnoreCase(uri, prefix)) return null; const rest = uri[prefix.len..]; const slash = std.mem.findScalar(u8, rest, '/') orelse return null; var host = rest[0..slash]; if (std.ascii.eqlIgnoreCase(host, "localhost")) host = ""; var path = rest[slash..]; if (host.len == 0) { // `/C:/x`, and nothing else is a path on this machine. if (path.len < 3 or !std.ascii.isAlphabetic(path[1]) or path[2] != ':') return null; path = path[1..]; } return .{ .host = host, .path = path };}
fn percentDecode(gpa: Allocator, out: *std.ArrayList(u8), s: []const u8) Allocator.Error!void { var i: usize = 0; while (i < s.len) : (i += 1) { if (s[i] == '%' and i + 2 < s.len) { if (std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16)) |b| { try out.append(gpa, b); i += 2; continue; } else |_| {} } try out.append(gpa, s[i]); }}
/// `s` as one word to a POSIX shell: as it is when nothing in it is/// special, and in single quotes otherwise.fn shellQuote(w: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void { const plain = s.len > 0 and for (s) |c| { if (!(std.ascii.isAlphanumeric(c) or std.mem.findScalar(u8, "_@%+=:,./-", c) != null)) break false; } else true; if (plain) return w.writeAll(s); try w.writeByte('\''); for (s) |c| { if (c == '\'') try w.writeAll("'\\''") else try w.writeByte(c); } try w.writeByte('\'');}
/// `s` as one word to cmd or PowerShell: as it is when nothing in it is/// special, and in double quotes otherwise, which a Windows path cannot/// contain.fn windowsQuote(w: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void { const plain = s.len > 0 and for (s) |c| { if (!(std.ascii.isAlphanumeric(c) or c >= 0x80 or std.mem.findScalar(u8, "_+=:,./\\-", c) != null)) break false; } else true; if (plain) return w.writeAll(s); try w.print("\"{s}\"", .{s});}
fn motion(d: *const Dnd, w: *const Window, p: *const Pane) dnd.DropInput.Motion { const drop = d.drop.?; return .{ .position = w.dndPosition(p, drop.x, drop.y), .operations = .{ .copy = drop.actions.copy, .move = drop.actions.move }, .mimes = drop.mimes, };}
/// Reports drop activity to `p`'s program, and acts on what it wants/// next. False if the program would not have it.fn dropInputTo(d: *Dnd, w: *Window, p: *Pane, input: dnd.DropInput) bool { var arena: std.heap.ArenaAllocator = .init(d.app.gpa); defer arena.deinit(); const next = p.session.dropInput(arena.allocator(), input) catch |err| { switch (err) { // It stopped accepting drops, or the request was abandoned. error.Inactive, error.Rejected => {}, else => log.warn("drop: {t}", .{err}), } return false; }; if (next) |e| d.dropEvent(w, p, e); return true;}
/// Answers a data request with a failure.fn fail(d: *Dnd, w: *Window, p: *Pane, id: u32, why: dnd.DropInput.Error) void { _ = d.dropInputTo(w, p, .{ .fail = .{ .id = id, .reason = why } });}
/// Starts reading what `p`'s program asked for of the drop.fn serve(d: *Dnd, w: *Window, p: *Pane, r: dnd.DropEvent.DataRequest) void { d.endRead(); if (r.path) |path| return if (is_linux) d.serveFile(w, p, r.id, path) else d.serveWindowsFile(w, p, r.id, path); const from = d.receive(w, r.mime) catch |err| { log.info("reading a drop as {s}: {t}", .{ r.mime, err }); return d.fail(w, p, r.id, .io); }; d.read = .{ .pane = p.id, .id = r.id, .from = from };}
/// Where to read the drop's data as `mime` from.fn receive(d: *Dnd, w: *Window, mime: []const u8) !From { const got = try w.pw.dropReceive(mime); if (is_linux) return got; // The platform's copy goes when the drop is finished, or another is // dropped. return .{ .memory = .{ .bytes = try d.app.gpa.dupe(u8, got) } };}
/// Says, once a drop, that its files are being sent.fn sendingFiles(d: *Dnd) void { if (d.drop) |*drop| if (!drop.sent_files) { drop.sent_files = true; log.info("sending the files dropped to a program on another machine", .{}); };}
/// A file a program on another machine wants of a drop that named it:/// what it is, and then its contents, the target of a symbolic link, or a/// directory's entries. Symbolic links are never followed.fn serveFile(d: *Dnd, w: *Window, p: *Pane, id: u32, path: []const u8) void { d.sendingFiles(); const gpa = d.app.gpa; const z = gpa.dupeSentinel(u8, path, 0) catch return d.fail(w, p, id, .out_of_memory); defer gpa.free(z);
var st: linux.Statx = undefined; const rc = linux.statx(linux.AT.FDCWD, z, linux.AT.SYMLINK_NOFOLLOW, .{ .TYPE = true }, &st); if (linux.errno(rc) != .SUCCESS) return d.fail(w, p, id, reason(linux.errno(rc))); switch (@as(u32, st.mode) & linux.S.IFMT) { linux.S.IFREG => { const fd = linux.open(z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NOFOLLOW = true, .NONBLOCK = true }, 0); if (linux.errno(fd) != .SUCCESS) return d.fail(w, p, id, reason(linux.errno(fd))); d.read = .{ .pane = p.id, .id = id, .from = @intCast(fd) }; }, linux.S.IFLNK => { var buf: [std.fs.max_path_bytes]u8 = undefined; const n = linux.readlink(z, &buf, buf.len); if (linux.errno(n) != .SUCCESS) return d.fail(w, p, id, reason(linux.errno(n))); if (!d.dropInputTo(w, p, .{ .kind = .{ .id = id, .kind = .symlink } })) return; if (!d.dropInputTo(w, p, .{ .data = .{ .id = id, .bytes = buf[0..n] } })) return; _ = d.dropInputTo(w, p, .{ .end = id }); }, linux.S.IFDIR => { const names = directoryEntries(gpa, z) catch |err| return d.fail(w, p, id, switch (err) { error.OutOfMemory => .out_of_memory, error.AccessDenied => .denied, error.NotFound => .not_found, error.Io => .io, }); defer gpa.free(names); if (!d.dropInputTo(w, p, .{ .kind = .{ .id = id, .kind = .directory } })) return; if (names.len > 0 and !d.dropInputTo(w, p, .{ .data = .{ .id = id, .bytes = names } })) return; _ = d.dropInputTo(w, p, .{ .end = id }); }, else => d.fail(w, p, id, .unsupported), }}
/// The names of a directory's regular files, directories and symbolic/// links, each followed by a NUL.fn directoryEntries(gpa: Allocator, path: [*:0]const u8) error{ OutOfMemory, AccessDenied, NotFound, Io }![]u8 { const fd = linux.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .DIRECTORY = true, .NOFOLLOW = true }, 0); switch (linux.errno(fd)) { .SUCCESS => {}, .ACCES, .PERM => return error.AccessDenied, .NOENT => return error.NotFound, else => return error.Io, } defer _ = linux.close(@intCast(fd)); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var buf: [8192]u8 align(@alignOf(linux.dirent64)) = undefined; while (true) { const n = linux.getdents64(@intCast(fd), &buf, buf.len); if (linux.errno(n) != .SUCCESS) return error.Io; if (n == 0) break; var off: usize = 0; while (off < n) { const ent: *align(1) linux.dirent64 = @ptrCast(&buf[off]); off += ent.reclen; const name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&ent.name)), 0); if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue; switch (ent.type) { linux.DT.REG, linux.DT.DIR, linux.DT.LNK => {}, else => continue, } try out.appendSlice(gpa, name); try out.append(gpa, 0); } } return out.toOwnedSlice(gpa);}
/// `serveFile` on Windows, where the program has the path from the drop's/// `file:///C:/…` as `/C:/…`.fn serveWindowsFile(d: *Dnd, w: *Window, p: *Pane, id: u32, path: []const u8) void { d.sendingFiles(); const gpa = d.app.gpa; const io = d.app.io; const native = (windowsFilePath(gpa, path) catch return d.fail(w, p, id, .out_of_memory)) orelse return d.fail(w, p, id, .not_found); defer gpa.free(native); const cwd = std.Io.Dir.cwd(); const st = cwd.statFile(io, native, .{ .follow_symlinks = false }) catch |err| return d.fail(w, p, id, ioReason(err)); switch (st.kind) { .file => { const file = cwd.openFile(io, native, .{ .follow_symlinks = false }) catch |err| return d.fail(w, p, id, ioReason(err)); d.read = .{ .pane = p.id, .id = id, .from = .{ .file = file } }; }, .sym_link => { var buf: [std.fs.max_path_bytes]u8 = undefined; const n = cwd.readLink(io, native, &buf) catch |err| return d.fail(w, p, id, ioReason(err)); if (!d.dropInputTo(w, p, .{ .kind = .{ .id = id, .kind = .symlink } })) return; if (!d.dropInputTo(w, p, .{ .data = .{ .id = id, .bytes = buf[0..n] } })) return; _ = d.dropInputTo(w, p, .{ .end = id }); }, .directory => { const names = windowsEntries(gpa, io, native) catch |err| return d.fail(w, p, id, ioReason(err)); defer gpa.free(names); if (!d.dropInputTo(w, p, .{ .kind = .{ .id = id, .kind = .directory } })) return; if (names.len > 0 and !d.dropInputTo(w, p, .{ .data = .{ .id = id, .bytes = names } })) return; _ = d.dropInputTo(w, p, .{ .end = id }); }, else => d.fail(w, p, id, .unsupported), }}
/// The Windows path of `/C:/dir/name`, a path a program has from a/// `file:` URI; null for any other.fn windowsFilePath(gpa: Allocator, path: []const u8) Allocator.Error!?[]u8 { if (path.len < 3 or path[0] != '/' or !std.ascii.isAlphabetic(path[1]) or path[2] != ':') return null; if (path.len > 3 and path[3] != '/') return null; const out = try gpa.dupe(u8, if (path.len == 3) "C:/" else path[1..]); if (path.len == 3) out[0] = path[1]; std.mem.replaceScalar(u8, out, '/', '\\'); return out;}
/// `directoryEntries` on Windows.fn windowsEntries(gpa: Allocator, io: std.Io, path: []const u8) ![]u8 { var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true, .follow_symlinks = false }); defer dir.close(io); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var it = dir.iterate(); while (try it.next(io)) |entry| { switch (entry.kind) { .file, .directory, .sym_link => {}, else => continue, } try out.appendSlice(gpa, entry.name); try out.append(gpa, 0); } return out.toOwnedSlice(gpa);}
fn ioReason(err: anyerror) dnd.DropInput.Error { return switch (err) { error.FileNotFound, error.NotDir => .not_found, error.AccessDenied, error.PermissionDenied => .denied, error.OutOfMemory, error.SystemResources => .out_of_memory, error.FileTooBig => .too_large, else => .io, };}
fn reason(e: linux.E) dnd.DropInput.Error { return switch (e) { .NOENT, .NOTDIR => .not_found, .ACCES, .PERM => .denied, .NOMEM => .out_of_memory, .FBIG => .too_large, else => .io, };}
fn endRead(d: *Dnd) void { var r = d.read orelse return; if (is_linux) { _ = linux.close(r.from); } else switch (r.from) { .memory => |m| d.app.gpa.free(m.bytes), .file => |f| f.close(d.app.io), } if (r.paste) |*b| b.deinit(d.app.gpa); d.read = null;}
// ---------------------------------------------------------------------------// Drags// ---------------------------------------------------------------------------
/// A press dragged to `x`, `y` in physical pixels, from where it was/// pressed: far enough, and over a program offering drags, it asks the/// program to offer one. True if it did.pub fn gesture(_: *Dnd, w: *Window, p: *Pane, x: f32, y: f32) bool { if (!p.dnd_offers) return false; p.session.dragInput(.{ .gesture = w.dndPosition(p, x, y) }) catch |err| switch (err) { error.Inactive => return false, else => { log.warn("starting a drag: {t}", .{err}); return false; }, }; return true;}
fn startDrag(d: *Dnd, w: *Window, p: *Pane, offer: dnd.DragEvent.Offer) void { const gpa = d.app.gpa; // A new drag from the pane: what was still being written of its last // one is not coming. d.dropWrites(p.id); if (d.drag) |*g| { d.endDrag(g); d.drag = null; } d.removeCopies(); var arena: std.heap.ArenaAllocator = .init(gpa); const result: dnd.DragInput.StartResult = result: { // The user has already let go. if (!w.left_down) break :result .denied; const items = (dnd.DragEvent{ .start = offer }).dupe(arena.allocator()) catch break :result .failed; const mimes = arena.allocator().alloc([]const u8, offer.items.len) catch break :result .failed; for (mimes, items.start.items) |*m, it| m.* = it.mime;
var pixels: std.ArrayList(u32) = .empty; defer pixels.deinit(gpa); const icon = if (offer.image) |i| if (i < offer.images.len) dragIcon(gpa, offer.images[i], w.scale, &pixels) else null else null; w.pw.startDrag(mimes, .{ .copy = offer.operations.copy, .move = offer.operations.move }, icon) catch |err| { log.info("starting a drag: {t}", .{err}); break :result .failed; }; d.drag = .{ .pane = p.id, .arena = arena, .items = items.start.items }; if (offer.remote) d.drag.?.remote = (if (is_linux) d.makeRemote(items.start.items) else d.makeWindowsRemote(items.start.items)) catch |err| { log.warn("a drag from another machine, with nowhere to put its files: {t}", .{err}); w.pw.cancelDrag(); d.drag = null; break :result .failed; }; break :result .started; }; if (result != .started) arena.deinit(); p.session.dragInput(.{ .start_result = result }) catch |err| log.info("starting a drag: {t}", .{err});}
/// The drag's image, when it is pixels; one that is a PNG or text would/// want decoding or setting first, and the drag goes without.fn dragIcon(gpa: Allocator, image: anytype, scale: f32, pixels: *std.ArrayList(u32)) ?ev.DragIcon { if (image.format != .rgba) return null; const n = @as(usize, image.width) * image.height; if (n == 0 or image.data.len < n * 4 or image.width > 1024 or image.height > 1024) return null; pixels.resize(gpa, n) catch return null; // RGBA bytes to premultiplied ARGB words. for (pixels.items, 0..) |*px, i| { const c = image.data[i * 4 ..][0..4]; const a: u32 = c[3]; const r: u32 = @as(u32, c[0]) * a / 255; const g: u32 = @as(u32, c[1]) * a / 255; const b: u32 = @as(u32, c[2]) * a / 255; px.* = a << 24 | r << 16 | g << 8 | b; } return .{ .width = image.width, .height = image.height, .pixels = pixels.items, // Drawn a pixel to a pixel on the screen. .scale = scale, .hot_x = @intCast(image.width / 2), .hot_y = @intCast(image.height / 2), };}
/// What the window system says of the drag `w` started.pub fn dragActivity(d: *Dnd, _: *Window, data: ev.Data) void { const g = d.drag orelse switch (data) { .drag_send => |s| { if (is_linux) _ = linux.close(s.fd); return; }, else => return, }; const found = d.app.findPane(g.pane); const p = if (found) |f| f.pane else null; const input: ?dnd.DragInput = switch (data) { .drag_target => |i| .{ .accepted = i }, .drag_action => |a| .{ .operation = switch (a) { .none => .none, .copy => .copy, .move => .move, } }, .drag_dropped => .dropped, .drag_finished => |cancelled| .{ .finished = cancelled }, .drag_send => |s| send: { const fetch = if (is_linux) d.send(g, s.index, s.fd) else d.handOver(g, s.index); // Asked for, unless it is here or already asked. if (!fetch or d.waiting(g.pane, s.index) > 1) break :send null; break :send .{ .request_data = s.index }; }, else => null, }; if (data == .drag_finished) { var gg = g; d.endDrag(&gg); d.drag = null; } const pane = p orelse return; const i = input orelse return; pane.session.dragInput(i) catch |err| switch (err) { error.Inactive, error.Rejected => {}, else => log.info("a drag: {t}", .{err}), };}
/// A drop target wants item `index` of the drag, down `fd`. True if it/// has to be fetched from the program.fn send(d: *Dnd, g: Drag, index: u32, fd: linux.fd_t) bool { _ = linux.fcntl(fd, linux.F.SETFL, @as(u32, @bitCast(linux.O{ .NONBLOCK = true }))); var wr: Write = .{ .pane = g.pane, .index = index, .fd = fd }; // The list of files from another machine: its files are fetched with // it, the first time, and it names them here after that. const listed: ?[]const u8 = if (g.remote) |r| if (r.uri_index == index) r.list orelse list: { wr.rewrite = true; break :list null; } else g.items[index].pre_sent else g.items[index].pre_sent; if (listed) |data| { wr.data.appendSlice(d.app.gpa, data) catch { closeWrite(d.app.gpa, &wr); return false; }; wr.complete = true; } d.writes.append(d.app.gpa, wr) catch { closeWrite(d.app.gpa, &wr); return false; }; return listed == null;}
/// A drop target wants item `index` of the drag, on Windows, where the/// window system has the data rather than a pipe. True if it has to be/// fetched from the program.fn handOver(d: *Dnd, g: Drag, index: u32) bool { // The list of files from another machine: fetched with its files the // first time, and naming them here after that. const listed = if (g.remote) |r| if (r.uri_index == index) r.list else g.items[index].pre_sent else g.items[index].pre_sent; const data = listed orelse return true; d.app.platform.dragData(index, data, .complete); return false;}
/// How many writes are waiting on the program for item `index`.fn waiting(d: *const Dnd, pane: u64, index: u32) usize { var n: usize = 0; for (d.writes.items) |wr| { if (wr.pane == pane and wr.index == index and !wr.complete) n += 1; } return n;}
/// Data the program sent for a drop target.fn dragData(d: *Dnd, p: *Pane, data: dnd.DragEvent.Data) void { if (!is_linux) { const g = &(d.drag orelse return); if (g.pane != p.id) return; if (g.remote) |*r| if (r.uri_index == data.index) return d.remoteList(g, r, data); return d.app.platform.dragData(data.index, data.bytes, switch (data.status) { .pending => .pending, .complete => .complete, .failed => .failed, }); } for (d.writes.items) |*wr| { if (wr.pane != p.id or wr.index != data.index or wr.complete) continue; switch (data.status) { .pending, .complete => { wr.data.appendSlice(d.app.gpa, data.bytes) catch { wr.complete = true; wr.data.clearRetainingCapacity(); wr.sent = 0; continue; }; wr.complete = data.status == .complete; if (wr.complete and wr.rewrite) d.rewriteList(wr); }, // What was sent stands; the target sees the pipe end early. .failed => wr.complete = true, } }}
/// The list of files from another machine, all here: rewritten to name/// the copies, and kept for the next target that asks.fn rewriteList(d: *Dnd, wr: *Write) void { wr.rewrite = false; const g = &(d.drag orelse return); const r = &(g.remote orelse return); const dir = d.remote_dir orelse return; const list = renameUris(g.arena.allocator(), wr.data.items, dir, &r.entries) catch { wr.data.clearRetainingCapacity(); return; }; r.list = list; wr.data.clearRetainingCapacity(); wr.data.appendSlice(d.app.gpa, list) catch wr.data.clearRetainingCapacity();}
/// `rewriteList` on Windows: the list of files from another machine, as/// it comes, handed to the window system once it is all here and/// rewritten. Its files came first, so the window system is told that/// the drag is still under way meanwhile.fn remoteList(d: *Dnd, g: *Drag, r: *Remote, data: dnd.DragEvent.Data) void { const platform = d.app.platform; switch (data.status) { .pending => { r.incoming.appendSlice(d.app.gpa, data.bytes) catch return platform.dragData(data.index, "", .failed); platform.dragProgress(); }, .complete => { r.incoming.appendSlice(d.app.gpa, data.bytes) catch return platform.dragData(data.index, "", .failed); const dir = d.remote_dir orelse return platform.dragData(data.index, "", .failed); const arena = g.arena.allocator(); const uri_dir = uriPathOf(arena, dir) catch return platform.dragData(data.index, "", .failed); const list = renameUris(arena, r.incoming.items, uri_dir, &r.entries) catch return platform.dragData(data.index, "", .failed); r.list = list; r.incoming.clearAndFree(d.app.gpa); platform.dragData(data.index, list, .complete); }, .failed => platform.dragData(data.index, "", .failed), }}
/// A Windows path as the path of a `file:` URI: `C:\a\b` as `/C:/a/b`.fn uriPathOf(arena: Allocator, path: []const u8) Allocator.Error![]const u8 { const out = try std.mem.concat(arena, u8, &.{ "/", path }); std.mem.replaceScalar(u8, out, '\\', '/'); return out;}
/// `list`, a text/uri-list, with each URI replaced by a `file:` URI for/// what its entry was written as under `dir`; entries not written are/// left out, and comments kept.fn renameUris(arena: Allocator, list: []const u8, dir: []const u8, entries: *const std.AutoHashMapUnmanaged(u32, []const u8)) Allocator.Error![]const u8 { var out: std.Io.Writer.Allocating = .init(arena); var entry: u32 = 0; var lines = std.mem.splitScalar(u8, list, '\n'); while (lines.next()) |raw| { const line = std.mem.trimEnd(u8, raw, "\r"); if (line.len == 0) continue; if (line[0] == '#') { out.writer.print("{s}\r\n", .{line}) catch return error.OutOfMemory; continue; } defer entry += 1; const rel = entries.get(entry) orelse continue; out.writer.writeAll("file://") catch return error.OutOfMemory; for ([_][]const u8{ dir, "/", rel }) |part| for (part) |c| { // A path's `:` as it is, which a Windows drive letter wants. const plain = std.ascii.isAlphanumeric(c) or std.mem.findScalar(u8, "/-._~:", c) != null; (if (plain) out.writer.writeByte(c) else out.writer.print("%{X:0>2}", .{c})) catch return error.OutOfMemory; }; out.writer.writeAll("\r\n") catch return error.OutOfMemory; } return out.toOwnedSlice();}
/// A file of a drag from another machine, or some of one.fn remoteFile(d: *Dnd, p: *Pane, f: dnd.DragEvent.RemoteFile) void { const g = &(d.drag orelse return); if (g.pane != p.id) return; const r = &(g.remote orelse return); if (!is_linux) { // A drop target is waiting for the list, which comes after these. d.app.platform.dragProgress(); return writeWindowsRemote(d.app.io, g.arena.allocator(), r, f) catch |err| { log.info("a file dragged from another machine, {s}: {t}", .{ f.path, err }); if (r.file) |file| file.close(d.app.io); r.file = null; }; } writeRemote(d.app.gpa, g.arena.allocator(), r, f) catch |err| { log.info("a file dragged from another machine, {s}: {t}", .{ f.path, err }); if (r.file) |fd| _ = linux.close(fd); r.file = null; r.link.clearRetainingCapacity(); };}
const RemoteError = error{ Invalid, Exists, Io, OutOfMemory };
/// `writeRemote` on Windows. Every part of the path is made a name Windows/// can hold, and nothing here makes a symbolic link, so a path made of/// such names stays inside `r.dir` without opening a part at a time.fn writeWindowsRemote(io: std.Io, arena: Allocator, r: *Remote, f: dnd.DragEvent.RemoteFile) RemoteError!void { if (f.status == .failed) { if (r.file) |file| file.close(io); r.file = null; return; } if (f.kind == .symlink) { if (f.status == .complete) log.info("a symbolic link dragged from another machine, not copied: {s}", .{f.path}); return; } if (f.kind == .directory or r.file == null) { var buf: [std.fs.max_path_bytes]u8 = undefined; const rel = try windowsRelative(&buf, f.path); // The entry's own directory, the first part, is made when it is // not there yet; a directory below it arrives before its entries. const slash = std.mem.findScalar(u8, rel, '\\') orelse return error.Invalid; r.dir.createDir(io, rel[0..slash], .default_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return error.Io, }; switch (f.kind) { .directory => r.dir.createDir(io, rel, .default_dir) catch |err| return switch (err) { error.PathAlreadyExists => error.Exists, else => error.Io, }, .file => r.file = r.dir.createFile(io, rel, .{ .exclusive = true }) catch |err| return switch (err) { error.PathAlreadyExists => error.Exists, else => error.Io, }, .symlink => unreachable, } // "<entry>/<name>" is what the list names. if (std.mem.findScalar(u8, rel[slash + 1 ..], '\\') == null) { const named = try arena.dupe(u8, rel); std.mem.replaceScalar(u8, named, '\\', '/'); r.entries.put(arena, f.entry, named) catch return error.OutOfMemory; } } if (f.kind == .file) { const file = r.file orelse return error.Invalid; file.writeStreamingAll(io, f.bytes) catch return error.Io; if (f.status == .complete) { file.close(io); r.file = null; } }}
/// A path of a file from another machine, `/`-separated, as a relative/// Windows path in `buf`: each part made a name Windows can hold, by/// `windowsName`.fn windowsRelative(buf: []u8, path: []const u8) RemoteError![]const u8 { var len: usize = 0; var parts = std.mem.splitScalar(u8, path, '/'); var first = true; while (parts.next()) |part| { if (part.len == 0 or std.mem.eql(u8, part, ".") or std.mem.eql(u8, part, "..")) return error.Invalid; if (!first) { if (len == buf.len) return error.Invalid; buf[len] = '\\'; len += 1; } first = false; len += try windowsName(buf[len..], part); } return buf[0..len];}
/// `name` written to `buf` as a name Windows can hold: what a Windows/// name cannot contain, and a space or dot it cannot end with, replaced/// with `_`, and `_` put before a device's name — `CON`, `NUL`, `COM1` and/// the rest, with or without an extension. Its length.fn windowsName(buf: []u8, name: []const u8) RemoteError!usize { const reserved = [_][]const u8{ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }; const stem = std.mem.trimEnd(u8, name[0 .. std.mem.findScalar(u8, name, '.') orelse name.len], " "); const device = for (reserved) |r| { if (std.ascii.eqlIgnoreCase(stem, r)) break true; } else false; const len = name.len + @intFromBool(device); if (len > buf.len) return error.Invalid; var i: usize = 0; if (device) { buf[0] = '_'; i = 1; } for (name, 0..) |c, j| { const last = j == name.len - 1; buf[i] = if (c < 0x20 or std.mem.findScalar(u8, "<>:\"/\\|?*", c) != null or (last and (c == '.' or c == ' '))) '_' else c; i += 1; } return len;}
fn writeRemote(gpa: Allocator, arena: Allocator, r: *Remote, f: dnd.DragEvent.RemoteFile) RemoteError!void { if (f.status == .failed) { if (r.file) |fd| _ = linux.close(fd); r.file = null; r.link.clearRetainingCapacity(); return; } // The first event of each: where it goes. const first = switch (f.kind) { .file => r.file == null, .symlink => r.link.items.len == 0, .directory => true, }; if (first) { const parent = try openParent(r.dir, f.path); defer if (parent.fd != r.dir) { _ = linux.close(parent.fd); }; var name_buf: [std.fs.max_name_bytes + 1]u8 = undefined; const name = std.fmt.bufPrintSentinel(&name_buf, "{s}", .{parent.name}, 0) catch return error.Invalid; switch (f.kind) { .directory => try check(linux.mkdirat(parent.fd, name, 0o755)), .file => { const fd = linux.openat(parent.fd, name, .{ .ACCMODE = .WRONLY, .CREAT = true, .EXCL = true, .NOFOLLOW = true, .CLOEXEC = true }, 0o644); try check(fd); r.file = @intCast(fd); }, .symlink => {}, } // "<entry>/<name>" is what the list names. if (std.mem.findScalar(u8, f.path, '/')) |slash| if (std.mem.findScalar(u8, f.path[slash + 1 ..], '/') == null) { r.entries.put(arena, f.entry, try arena.dupe(u8, f.path)) catch return error.OutOfMemory; }; } switch (f.kind) { .directory => {}, .file => { const fd = r.file orelse return error.Invalid; var sent: usize = 0; while (sent < f.bytes.len) { const n = linux.write(fd, f.bytes[sent..].ptr, f.bytes.len - sent); switch (linux.errno(n)) { .SUCCESS => sent += n, .INTR => {}, else => return error.Io, } } if (f.status == .complete) { _ = linux.close(fd); r.file = null; } }, .symlink => { try r.link.appendSlice(gpa, f.bytes); if (f.status == .complete) { defer r.link.clearRetainingCapacity(); const parent = try openParent(r.dir, f.path); defer if (parent.fd != r.dir) { _ = linux.close(parent.fd); }; var name_buf: [std.fs.max_name_bytes + 1]u8 = undefined; const name = std.fmt.bufPrintSentinel(&name_buf, "{s}", .{parent.name}, 0) catch return error.Invalid; const target = try gpa.dupeSentinel(u8, r.link.items, 0); defer gpa.free(target); try check(linux.symlinkat(target, parent.fd, name)); } }, }}
/// The directory that holds `path`, relative to `root`, and the last part/// of it: opened a part at a time, never following a symbolic link, so/// that nothing a program names lands outside `root`. The entry's own/// directory, the first part, is made when it is not there yet.fn openParent(root: linux.fd_t, path: []const u8) RemoteError!struct { fd: linux.fd_t, name: []const u8 } { var parts = std.mem.splitScalar(u8, path, '/'); var fd = root; errdefer if (fd != root) { _ = linux.close(fd); }; var part = parts.next() orelse return error.Invalid; var depth: usize = 0; while (parts.next()) |next| : (depth += 1) { if (part.len == 0 or std.mem.eql(u8, part, ".") or std.mem.eql(u8, part, "..")) return error.Invalid; var buf: [std.fs.max_name_bytes + 1]u8 = undefined; const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{part}, 0) catch return error.Invalid; if (depth == 0) switch (linux.errno(linux.mkdirat(fd, z, 0o755))) { .SUCCESS, .EXIST => {}, else => return error.Io, }; const sub = linux.openat(fd, z, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .NOFOLLOW = true, .CLOEXEC = true }, 0); try check(sub); if (fd != root) _ = linux.close(fd); fd = @intCast(sub); part = next; } if (part.len == 0 or std.mem.eql(u8, part, ".") or std.mem.eql(u8, part, "..")) return error.Invalid; return .{ .fd = fd, .name = part };}
fn check(rc: usize) RemoteError!void { return switch (linux.errno(rc)) { .SUCCESS => {}, .EXIST => error.Exists, else => error.Io, };}
/// A new directory for the files of a drag from another machine, under/// the user's cache.fn makeRemote(d: *Dnd, items: []const dnd.DragEvent.Offer.Item) !Remote { const gpa = d.app.gpa; const env = d.app.env; const base = if (env.get("XDG_CACHE_HOME")) |c| try std.fmt.allocPrint(gpa, "{s}/harrier/drags", .{c}) else if (env.get("HOME")) |h| try std.fmt.allocPrint(gpa, "{s}/.cache/harrier/drags", .{h}) else return error.NoCacheDirectory; defer gpa.free(base); std.Io.Dir.cwd().createDirPath(d.app.io, base) catch return error.NoCacheDirectory;
var n: u32 = 0; const path = while (n < 1000) : (n += 1) { const p = try std.fmt.allocPrintSentinel(gpa, "{s}/{d}-{d}", .{ base, linux.getpid(), n }, 0); switch (linux.errno(linux.mkdirat(linux.AT.FDCWD, p, 0o700))) { .SUCCESS => break p, .EXIST => gpa.free(p), else => { gpa.free(p); return error.NoCacheDirectory; }, } } else return error.NoCacheDirectory; errdefer gpa.free(path); const fd = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .NOFOLLOW = true, .CLOEXEC = true }, 0); if (linux.errno(fd) != .SUCCESS) return error.NoCacheDirectory; d.remote_dir = path; log.info("a drag from another machine: its files go under {s}", .{path}); removeAbandoned(d.app.io, base); return .{ .dir = @intCast(fd), .uri_index = uriIndex(items) };}
/// `makeRemote` on Windows, under `%LOCALAPPDATA%\harrier\drags`.fn makeWindowsRemote(d: *Dnd, items: []const dnd.DragEvent.Offer.Item) !Remote { const win32 = @import("win32").everything; const gpa = d.app.gpa; const io = d.app.io; const local = d.app.env.get("LOCALAPPDATA") orelse return error.NoCacheDirectory; const base = try std.fs.path.join(gpa, &.{ local, "harrier", "drags" }); defer gpa.free(base); const cwd = std.Io.Dir.cwd(); cwd.createDirPath(io, base) catch return error.NoCacheDirectory;
const pid = win32.GetCurrentProcessId(); var n: u32 = 0; const path = while (n < 1000) : (n += 1) { const p = try std.fmt.allocPrintSentinel(gpa, "{s}\\{d}-{d}", .{ base, pid, n }, 0); cwd.createDir(io, p, .default_dir) catch |err| { gpa.free(p); switch (err) { error.PathAlreadyExists => continue, else => return error.NoCacheDirectory, } }; break p; } else return error.NoCacheDirectory; errdefer gpa.free(path); const dir = cwd.openDir(io, path, .{ .follow_symlinks = false }) catch return error.NoCacheDirectory; d.remote_dir = path; log.info("a drag from another machine: its files go under {s}", .{path}); removeWindowsAbandoned(io, base); return .{ .dir = dir, .uri_index = uriIndex(items) };}
/// Which item is the text/uri-list.fn uriIndex(items: []const dnd.DragEvent.Offer.Item) ?u32 { var uri_index: ?u32 = null; for (items, 0..) |it, i| if (std.mem.eql(u8, it.mime, "text/uri-list")) { uri_index = @intCast(i); }; return uri_index;}
/// `removeAbandoned` on Windows.fn removeWindowsAbandoned(io: std.Io, base: []const u8) void { const win32 = @import("win32").everything; var dir = std.Io.Dir.cwd().openDir(io, base, .{ .iterate = true }) catch return; defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch null) |entry| { const dash = std.mem.findScalar(u8, entry.name, '-') orelse continue; const pid = std.fmt.parseInt(u32, entry.name[0..dash], 10) catch continue; if (pid == win32.GetCurrentProcessId()) continue; // Running, if it can be opened and has not exited. if (win32.OpenProcess(win32.PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)) |h| { defer _ = win32.CloseHandle(h); var code: u32 = 0; if (win32.GetExitCodeProcess(h, &code) == 0 or code == @backingInt(win32.STILL_ACTIVE)) continue; } dir.deleteTree(io, entry.name) catch {}; }}
/// Removes what harriers no longer running left under `base`: one killed/// before it could tidy up.fn removeAbandoned(io: std.Io, base: []const u8) void { var dir = std.Io.Dir.cwd().openDir(io, base, .{ .iterate = true }) catch return; defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch null) |entry| { const dash = std.mem.findScalar(u8, entry.name, '-') orelse continue; const pid = std.fmt.parseInt(linux.pid_t, entry.name[0..dash], 10) catch continue; if (pid == linux.getpid() or linux.errno(linux.kill(pid, @fromBackingInt(0))) != .SRCH) continue; dir.deleteTree(io, entry.name) catch {}; }}
fn endDrag(d: *Dnd, g: *Drag) void { if (g.remote) |*r| { if (is_linux) { if (r.file) |fd| _ = linux.close(fd); _ = linux.close(r.dir); } else { if (r.file) |file| file.close(d.app.io); r.dir.close(d.app.io); } r.link.deinit(d.app.gpa); r.incoming.deinit(d.app.gpa); } g.arena.deinit();}
/// Removes the copies of the last drag from another machine.fn removeCopies(d: *Dnd) void { const path = d.remote_dir orelse return; d.remote_dir = null; defer d.app.gpa.free(path); std.Io.Dir.cwd().deleteTree(d.app.io, path) catch |err| log.info("removing {s}: {t}", .{ path, err });}
fn dropWrites(d: *Dnd, pane: u64) void { var i: usize = 0; while (i < d.writes.items.len) { if (d.writes.items[i].pane == pane) { closeWrite(d.app.gpa, &d.writes.items[i]); _ = d.writes.swapRemove(i); } else i += 1; }}
fn closeWrite(gpa: Allocator, wr: *Write) void { if (is_linux) _ = linux.close(wr.fd); wr.data.deinit(gpa);}
// ---------------------------------------------------------------------------// The loop// ---------------------------------------------------------------------------
/// What the loop waits on for drag and drop: pipes to read from, and pipes/// to write to.pub fn handles(d: *const Dnd, gpa: Allocator, in: *std.ArrayList(Handle), out: *std.ArrayList(Handle)) Allocator.Error!void { if (!is_linux) return; if (d.read) |r| try in.append(gpa, r.from); for (d.writes.items) |wr| if (!wr.rewrite and wr.sent < wr.data.items.len) try out.append(gpa, wr.fd);}
/// Whether there is more to read that the loop has nothing to wait on/// for, and so should not wait: a drop's data, from memory, on Windows.pub fn busy(d: *const Dnd) bool { return !is_linux and d.read != null;}
/// Moves what can be moved without waiting.pub fn pump(d: *Dnd) void { d.pumpRead(); d.pumpWrites();}
fn pumpRead(d: *Dnd) void { var turns: usize = 0; while (turns < reads_per_turn) : (turns += 1) { if (d.read == null) return; const r = &d.read.?; const id = r.id; const found = d.app.findPane(r.pane) orelse return d.endRead(); var buf: [read_chunk]u8 = undefined; const got = readSome(d.app.io, r, &buf); if (got == .again) return; if (r.paste) |*b| { const n = switch (got) { .n => |n| n, else => { d.endRead(); d.drop = null; return found.window.pw.dropFinish(.none); }, }; if (n == 0 or b.items.len + n > max_paste) { if (n != 0) log.info("a drop too large to paste", .{}); if (n == 0) d.pasteDrop(r) else { d.drop = null; found.window.pw.dropFinish(.none); } return d.endRead(); } b.appendSlice(d.app.gpa, buf[0..n]) catch { d.endRead(); d.drop = null; return found.window.pw.dropFinish(.none); }; continue; } const n = switch (got) { .n => |n| n, .again => unreachable, .failed => |why| { d.endRead(); return d.fail(found.window, found.pane, id, why); }, }; if (n == 0) { // Done: and the answer may be the next request. d.endRead(); _ = d.dropInputTo(found.window, found.pane, .{ .end = id }); return; } if (!d.dropInputTo(found.window, found.pane, .{ .data = .{ .id = id, .bytes = buf[0..n] } })) return d.endRead(); }}
const ReadResult = union(enum) { /// How much was read: none at the end. n: usize, /// Nothing yet. again, failed: dnd.DropInput.Error,};
/// Reads what there is of `r`, up to `buf`'s length.fn readSome(io: std.Io, r: *Read, buf: []u8) ReadResult { if (is_linux) { const n = linux.read(r.from, buf.ptr, buf.len); return switch (linux.errno(n)) { .SUCCESS => .{ .n = n }, .AGAIN, .INTR => .again, else => |e| .{ .failed = reason(e) }, }; } switch (r.from) { .memory => |*m| { const n = @min(buf.len, m.bytes.len - m.at); @memcpy(buf[0..n], m.bytes[m.at..][0..n]); m.at += n; return .{ .n = n }; }, .file => |f| { const n = f.readStreaming(io, &.{buf}) catch |err| return switch (err) { error.EndOfStream => .{ .n = 0 }, else => .{ .failed = ioReason(err) }, }; // Nothing read is not the end, which says so. return if (n == 0) .again else .{ .n = n }; }, }}
fn pumpWrites(d: *Dnd) void { if (!is_linux) return; var i: usize = 0; while (i < d.writes.items.len) { const wr = &d.writes.items[i]; var failed = false; while (!wr.rewrite and wr.sent < wr.data.items.len) { const rest = wr.data.items[wr.sent..]; const n = linux.write(wr.fd, rest.ptr, rest.len); switch (linux.errno(n)) { .SUCCESS => wr.sent += n, .INTR => {}, .AGAIN => break, else => { failed = true; break; }, } } // What has been written is not kept. if (wr.sent == wr.data.items.len) { wr.data.clearRetainingCapacity(); wr.sent = 0; } if (failed or (wr.complete and wr.data.items.len == 0)) { closeWrite(d.app.gpa, wr); _ = d.writes.swapRemove(i); } else i += 1; }}
fn contains(list: []const []const u8, s: []const u8) bool { for (list) |x| if (std.mem.eql(u8, x, s)) return true; return false;}
const testing = std.testing;
test "directory entries end each name with a NUL and skip dot entries" { if (!is_linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "sub"); (try tmp.dir.createFile(testing.io, "file", .{})).close(testing.io); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const n = try tmp.dir.realPath(testing.io, &path_buf); path_buf[n] = 0; const names = try directoryEntries(testing.allocator, path_buf[0..n :0]); defer testing.allocator.free(names); var seen: usize = 0; var it = std.mem.splitScalar(u8, names, 0); while (it.next()) |name| { if (name.len == 0) continue; try testing.expect(std.mem.eql(u8, name, "sub") or std.mem.eql(u8, name, "file")); seen += 1; } try testing.expectEqual(@as(usize, 2), seen); try testing.expectEqual(@as(u8, 0), names[names.len - 1]);}
test "an RGBA image becomes a premultiplied drag icon" { const Image = @typeInfo(@FieldType(dnd.DragEvent.Offer, "images")).pointer.child; const image: Image = .{ .format = .rgba, .width = 1, .height = 1, .opacity = 0, .data = &.{ 255, 0, 0, 128 } }; var pixels: std.ArrayList(u32) = .empty; defer pixels.deinit(testing.allocator); const icon = dragIcon(testing.allocator, image, 1, &pixels).?; try testing.expectEqual(@as(u32, 0x80800000), icon.pixels[0]);}
test "a list of files from another machine names the copies" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var entries: std.AutoHashMapUnmanaged(u32, []const u8) = .empty; try entries.put(arena.allocator(), 0, "0/a file.txt"); try entries.put(arena.allocator(), 2, "2/dir"); const out = try renameUris(arena.allocator(), "# from afar\r\nfile:///x/a%20file.txt\r\nfile:///x/missing\r\nfile:///x/dir\r\n", "/c/1-0", &entries); try testing.expectEqualStrings("# from afar\r\nfile:///c/1-0/0/a%20file.txt\r\nfile:///c/1-0/2/dir\r\n", out);}
test "a remote file's path never leads out of its directory" { if (!is_linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const root = tmp.dir.handle; // A symbolic link the program made, then a path through it. try check(linux.mkdirat(root, "0", 0o755)); try check(linux.symlinkat("/", root, "0/out")); try testing.expectError(error.Io, openParent(root, "0/out/etc")); try testing.expectError(error.Invalid, openParent(root, "0/../x")); const ok = try openParent(root, "1/name"); defer _ = linux.close(ok.fd); try testing.expectEqualStrings("name", ok.name);}
test "dropped files paste as quoted paths" { const out = try pathsOf(testing.allocator, "# a comment\r\nfile:///home/me/a%20b.txt\r\nfile://localhost/tmp/plain\r\nfile://elsewhere/x\r\nhttps://example.com/?q=1\r\nfile:///it's\r\n", .posix); defer testing.allocator.free(out); try testing.expectEqualStrings("'/home/me/a b.txt' /tmp/plain file://elsewhere/x 'https://example.com/?q=1' '/it'\\''s'", out);}
test "dropped files paste as Windows paths" { const out = try pathsOf(testing.allocator, "file:///C:/Users/me/a%20b.txt\r\nfile://localhost/D:/plain\r\nfile://server/share/x%26y\r\nfile:///no-drive\r\nhttps://example.com/?q=1\r\n", .windows); defer testing.allocator.free(out); try testing.expectEqualStrings("\"C:\\Users\\me\\a b.txt\" D:\\plain \"\\\\server\\share\\x&y\" file:///no-drive \"https://example.com/?q=1\"", out);}
test "names from another machine become names Windows can hold" { var buf: [64]u8 = undefined; const cases = [_][2][]const u8{ .{ "plain.txt", "plain.txt" }, .{ "a:b<c>|d?*\"e\\f", "a_b_c__d___e_f" }, .{ "ends.", "ends_" }, .{ "ends ", "ends_" }, .{ "con", "_con" }, .{ "NUL.txt", "_NUL.txt" }, .{ "com1 .log", "_com1 .log" }, .{ "console", "console" }, .{ "tab\there", "tab_here" }, }; for (cases) |c| try testing.expectEqualStrings(c[1], buf[0..try windowsName(&buf, c[0])]); try testing.expectEqualStrings("0\\_aux\\x_y", try windowsRelative(&buf, "0/aux/x:y")); try testing.expectError(error.Invalid, windowsRelative(&buf, "0/../x")); try testing.expectError(error.Invalid, windowsRelative(&buf, "0//x")); try testing.expectError(error.Invalid, windowsName(buf[0..3], "long"));}
test "a program's path of a dropped file is a Windows path" { const gpa = testing.allocator; const a = (try windowsFilePath(gpa, "/C:/Users/me/a b.txt")).?; defer gpa.free(a); try testing.expectEqualStrings("C:\\Users\\me\\a b.txt", a); const root = (try windowsFilePath(gpa, "/D:")).?; defer gpa.free(root); try testing.expectEqualStrings("D:\\", root); try testing.expectEqual(@as(?[]u8, null), try windowsFilePath(gpa, "/home/me")); try testing.expectEqual(@as(?[]u8, null), try windowsFilePath(gpa, "/C:x"));}
test "the copies of a list from another machine are named by Windows paths" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var entries: std.AutoHashMapUnmanaged(u32, []const u8) = .empty; try entries.put(arena.allocator(), 0, "0/a b.txt"); const dir = try uriPathOf(arena.allocator(), "C:\\Users\\me\\AppData\\Local\\harrier\\drags\\12-0"); const out = try renameUris(arena.allocator(), "file:///home/them/a%20b.txt\r\n", dir, &entries); try testing.expectEqualStrings("file:///C:/Users/me/AppData/Local/harrier/drags/12-0/0/a%20b.txt\r\n", out);}
test "files from another machine are written under their directory on Windows" { if (is_linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var r: Remote = .{ .dir = tmp.dir, .uri_index = 0 }; const io = testing.io; try writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 0, .path = "0/dir", .kind = .directory, .bytes = "", .status = .complete }); try writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 0, .path = "0/dir/a:b", .kind = .file, .bytes = "one ", .status = .pending }); try writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 0, .path = "0/dir/a:b", .kind = .file, .bytes = "two", .status = .complete }); try writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 1, .path = "1/link", .kind = .symlink, .bytes = "/etc", .status = .complete }); try writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 2, .path = "2/con", .kind = .file, .bytes = "x", .status = .complete }); try testing.expectError(error.Invalid, writeWindowsRemote(io, arena.allocator(), &r, .{ .entry = 3, .path = "3/../../out", .kind = .file, .bytes = "x", .status = .complete }));
var buf: [16]u8 = undefined; try testing.expectEqualStrings("one two", try tmp.dir.readFile(io, "0\\dir\\a_b", &buf)); try testing.expectEqualStrings("x", try tmp.dir.readFile(io, "2\\_con", &buf)); try testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "1\\link", .{ .follow_symlinks = false })); try testing.expectEqualStrings("0/dir", r.entries.get(0).?); try testing.expectEqualStrings("2/_con", r.entries.get(2).?); try testing.expectEqual(@as(?[]const u8, null), r.entries.get(1));}
test "a directory dropped for another machine lists its entries on Windows" { if (is_linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = testing.io; try tmp.dir.createDirPath(io, "sub"); (try tmp.dir.createFile(io, "file", .{})).close(io); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const n = try tmp.dir.realPath(io, &path_buf); const names = try windowsEntries(testing.allocator, io, path_buf[0..n]); defer testing.allocator.free(names); var seen: usize = 0; var it = std.mem.splitScalar(u8, names, 0); while (it.next()) |name| { if (name.len == 0) continue; try testing.expect(std.mem.eql(u8, name, "sub") or std.mem.eql(u8, name, "file")); seen += 1; } try testing.expectEqual(@as(usize, 2), seen);}