diff --git a/.gitignore b/.gitignore index 1866b39..3d65e20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -node_modules/ +node_modules dist/ *.bun-build diff --git a/desktop/main.js b/desktop/main.js index 6676f1b..debfe31 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -189,7 +189,7 @@ ipcMain.handle("app:init", () => { const layout = config.restore ? readJson(layoutFile()) : undefined; const open = pendingSpec; pendingSpec = undefined; - return { config, layout, open, version: VERSION, platform: process.platform }; + return { config, layout, open, version: VERSION, platform: process.platform, smoke: !!process.env.HQTERM_DESKTOP_SMOKE }; }); ipcMain.handle("hosts:list", () => listHosts()); @@ -276,6 +276,10 @@ ipcMain.handle("clipboard:read", () => clipboard.readText()); ipcMain.on("clipboard:write", (_e, text) => { if (typeof text === "string") clipboard.writeText(text); }); +// PRIMARY selection exists only on Linux (X11/Wayland); a no-op elsewhere. +ipcMain.on("selection:write", (_e, text) => { + if (typeof text === "string" && process.platform === "linux") clipboard.writeText(text, "selection"); +}); ipcMain.on("open:url", (_e, url) => { if (typeof url === "string" && /^https?:\/\//.test(url)) shell.openExternal(url); }); @@ -328,6 +332,33 @@ if (!app.requestSingleInstanceLock()) { win.webContents.sendInputEvent({ type: "keyDown", keyCode: "D", modifiers: ["control", "shift"] }); win.webContents.sendInputEvent({ type: "keyUp", keyCode: "D", modifiers: ["control", "shift"] }); }, wait * 0.7); + // Shift+drag must leave a selection that survives the mouse moving on, + // even when the app tracks every motion (hqtui hover, DECSET 1003). + let selection = "not run"; + setTimeout(() => { + const p = [...ptys.values()].pop(); + if (p) p.write("clear; printf 'select me please\\n\\033[?1003h'\r"); + }, wait * 0.75); + setTimeout(async () => { + if (!win) return; + try { + const r = await win.webContents.executeJavaScript(`(() => { + const b = document.querySelector(".pane.focused .xterm-screen").getBoundingClientRect(); + return { x: Math.round(b.left + 4), y: Math.round(b.top + 6), mode: window.hqSmoke.mouseMode() }; + })()`); + const send = (type, x, modifiers) => win.webContents.sendInputEvent({ type, x, y: r.y, button: "left", clickCount: 1, modifiers }); + send("mouseDown", r.x, ["shift"]); + for (let x = r.x; x <= r.x + 80; x += 10) send("mouseMove", x, ["shift", "leftButtonDown"]); + send("mouseUp", r.x + 80, ["shift"]); + await new Promise((res) => setTimeout(res, 100)); + for (let x = r.x + 80; x <= r.x + 140; x += 10) win.webContents.sendInputEvent({ type: "mouseMove", x, y: r.y + 20 }); + await new Promise((res) => setTimeout(res, 300)); + const sel = await win.webContents.executeJavaScript("window.hqSmoke.selection()"); + selection = r.mode !== "any" ? `mouse mode ${r.mode}, not any` : sel ? "" : "cleared after release"; + } catch (err) { + selection = `error: ${err && err.message}`; + } + }, wait * 0.85); setTimeout(async () => { const shot = process.env.HQTERM_DESKTOP_SMOKE_SHOT; if (shot && win) { @@ -357,6 +388,10 @@ if (!app.requestSingleInstanceLock()) { console.log(`hqterm-desktop smoke: clipped: ${clipped.join("; ")}`); return app.exit(4); } + if (selection) { + console.log(`hqterm-desktop smoke: selection: ${selection}`); + return app.exit(5); + } console.log(`hqterm-desktop smoke: ok (ptys=${ptys.size})`); app.exit(ptys.size > 0 ? 0 : 3); }, wait); diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 73d80dd..35ca7f9 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hqterm-desktop", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hqterm-desktop", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/desktop/package.json b/desktop/package.json index 5bf0a88..a45ce45 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "hqterm-desktop", "productName": "hqterm", - "version": "0.3.0", + "version": "0.3.1", "description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).", "license": "MIT", "author": "Profullstack, Inc. ", diff --git a/desktop/preload.js b/desktop/preload.js index 7d46ba3..4657e66 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -15,5 +15,6 @@ contextBridge.exposeInMainWorld("hq", { onOpen: (fn) => ipcRenderer.on("app:open", (_e, spec) => fn(spec)), readClipboard: () => ipcRenderer.invoke("clipboard:read"), writeClipboard: (text) => ipcRenderer.send("clipboard:write", text), + writeSelection: (text) => ipcRenderer.send("selection:write", text), openUrl: (url) => ipcRenderer.send("open:url", url), }); diff --git a/desktop/src/app.js b/desktop/src/app.js index 5ff2aa9..9c8b478 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -34,6 +34,14 @@ const early = new Map(); // pty id -> { data: string[], exit?: [code, signal] } let webglCount = 0; + // CI smoke test (main.js) checks the focused pane's selection through this. + if (init.smoke) { + window.hqSmoke = { + mouseMode: () => focusedRt()?.term.modes.mouseTrackingMode, + selection: () => focusedRt()?.term.getSelection() || "", + }; + } + // ---------- PTY plumbing ---------- hq.onData((id, data) => { @@ -117,6 +125,23 @@ rt.title = t; renderBar(); }); + // Shift+drag selects even when the app tracks the mouse, but xterm.js counts + // every mouse report as user input and clears the selection on it. With + // any-motion tracking (hqtui's hover) the first move after releasing the + // button wiped the selection, so hold back hover reports while text is selected. + el.addEventListener( + "mousemove", + (e) => { + if (!e.buttons && term.modes.mouseTrackingMode === "any" && term.hasSelection()) e.stopPropagation(); + }, + true, + ); + // Like other Linux terminals, a selection is also the PRIMARY selection + // (middle-click pastes it); Ctrl+Shift+C still copies to the clipboard. + term.onSelectionChange(() => { + const sel = term.getSelection(); + if (sel) hq.writeSelection(sel); + }); // One click focuses a pane. el.addEventListener("mousedown", () => setFocus(pane.id), true); el.addEventListener("focusin", () => setFocus(pane.id, false)); diff --git a/package.json b/package.json index 383d7dd..70d24e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqterm", - "version": "0.3.0", + "version": "0.3.1", "description": "Terminal session manager: persistent hqsh sessions with images and HD emoji, built on hqtui.", "license": "MIT", "private": true,