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
1 change: 0 additions & 1 deletion src-tauri/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,6 @@ pub fn backup_export(
}
}
let data = state.export_all()?;
let mut files = files;
let mut secrets = data.secrets;
let mut clocks = data.clocks;
let excluded: HashSet<String> = excluded_ids.unwrap_or_default().into_iter().collect();
Expand Down
4 changes: 4 additions & 0 deletions src/components/terminal/terminalClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export function attachTerminalClipboard(
};

const handleMouseUp = (e: MouseEvent) => {
// Copy-on-select is opt-out: when the "Select to Copy" toggle is off, a
// selection must NOT touch the clipboard (#50). Explicit Ctrl+Shift+C still
// copies regardless.
if (!getToggle("select-to-copy")) return;
setTimeout(() => {
const sel = term.getSelection();
if (sel) {
Expand Down
41 changes: 41 additions & 0 deletions src/components/terminal/terminalWheelCore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { wheelToRows } from "./terminalWheelCore.ts";
import { test } from "vitest";

test("terminalWheelCore", async () => {
function assertEqual<T>(actual: T, expected: T, msg: string): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { console.error(`FAIL ${msg}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); throw new Error(msg); }
}

// Pixel mode (touchpads / most browsers): one cell-height down → one row down, no carry.
assertEqual(wheelToRows({ deltaY: 17, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 1, carry: 0 }, "one cell down");
assertEqual(wheelToRows({ deltaY: -17, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: -1, carry: 0 }, "one cell up");

// Touchpad: many sub-cell pixel deltas accumulate across calls until they cross a row.
const a = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0 });
assertEqual(a, { rows: 0, carry: 6 }, "sub-row 1 accumulates");
const b = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: a.carry });
assertEqual(b, { rows: 0, carry: 12 }, "sub-row 2 accumulates");
const c = wheelToRows({ deltaY: 6, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: b.carry });
assertEqual(c, { rows: 1, carry: 1 }, "sub-row 3 crosses one row, remainder carried");

// Reversing direction nets against the carried remainder rather than double-counting.
assertEqual(wheelToRows({ deltaY: -7, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 10 }), { rows: 0, carry: 3 }, "reversal nets carry");

// Line mode (deltaMode 1): deltaY is already in rows, scaled via cellHeight.
assertEqual(wheelToRows({ deltaY: 3, deltaMode: 1, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 3, carry: 0 }, "line mode 3 rows");

// Page mode (deltaMode 2): a page is viewportRows rows.
assertEqual(wheelToRows({ deltaY: 1, deltaMode: 2, cellHeight: 17, viewportRows: 24, carry: 0 }), { rows: 24, carry: 0 }, "page mode one page down");

// Degenerate cell height must not divide-by-zero or emit movement.
assertEqual(wheelToRows({ deltaY: 100, deltaMode: 0, cellHeight: 0, viewportRows: 24, carry: 0 }), { rows: 0, carry: 0 }, "zero cell height is inert");

// Zero delta is inert but preserves any existing carry.
assertEqual(wheelToRows({ deltaY: 0, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 5 }), { rows: 0, carry: 5 }, "zero delta preserves carry");

// maxRows clamps a fling to a page and drops the excess (carry reset) so the PTY
// isn't flooded with hundreds of arrows from one gesture.
assertEqual(wheelToRows({ deltaY: 5000, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: 24, carry: 0 }, "clamp down to a page");
assertEqual(wheelToRows({ deltaY: -5000, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: -24, carry: 0 }, "clamp up to a page");
assertEqual(wheelToRows({ deltaY: 34, deltaMode: 0, cellHeight: 17, viewportRows: 24, carry: 0, maxRows: 24 }), { rows: 2, carry: 0 }, "within clamp unaffected");
});
45 changes: 45 additions & 0 deletions src/components/terminal/terminalWheelCore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** Pure wheel-delta → terminal-row math for alternate-screen scroll translation.
* No DOM/xterm; node-testable. Full-screen apps (nano/less/vim) run in the
* alternate buffer where xterm has no scrollback to move, so a wheel/touchpad
* scroll is translated into Up/Down arrow presses. This converts a WheelEvent's
* delta into a signed row count, carrying the sub-row remainder across events so
* touchpads (which emit many tiny pixel deltas) still scroll smoothly. */
export interface WheelToRowsInput {
/** WheelEvent.deltaY. */
deltaY: number;
/** WheelEvent.deltaMode: 0 = pixels, 1 = lines, 2 = pages. */
deltaMode: number;
/** Rendered height of one terminal row, in CSS pixels. */
cellHeight: number;
/** Rows in the viewport (a "page" in deltaMode 2). */
viewportRows: number;
/** Sub-row pixel remainder carried from the previous call. */
carry: number;
/** Cap on rows emitted from a single event (a fling shouldn't flood the PTY).
* When the movement exceeds it, the result is clamped and the remainder dropped. */
maxRows?: number;
}

export interface WheelToRowsResult {
/** Rows to move: negative = up, positive = down. */
rows: number;
/** Sub-row pixel remainder to feed into the next call. */
carry: number;
}

export function wheelToRows({ deltaY, deltaMode, cellHeight, viewportRows, carry, maxRows }: WheelToRowsInput): WheelToRowsResult {
if (cellHeight <= 0) return { rows: 0, carry: 0 };

// Normalize the delta to pixels regardless of the browser's reporting mode.
let pixels: number;
if (deltaMode === 1) pixels = deltaY * cellHeight; // lines
else if (deltaMode === 2) pixels = deltaY * viewportRows * cellHeight; // pages
else pixels = deltaY; // pixels

const total = carry + pixels;
const rows = Math.trunc(total / cellHeight);
if (maxRows !== undefined && Math.abs(rows) > maxRows) {
return { rows: rows < 0 ? -maxRows : maxRows, carry: 0 };
}
return { rows, carry: total - rows * cellHeight };
}
77 changes: 61 additions & 16 deletions src/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { useTeamSessionStore } from "@/stores/teamSessionStore";
import { useCommandHistoryStore } from "@/stores/commandHistoryStore";
import { consumeLatchForChar } from "@/stores/modifierLatchStore";
import { sampleLineDensities, scrollDeltaForRatio, type TerminalMinimapCell, type TerminalMinimapSample } from "@/components/terminal/minimapMath";
import { wheelToRows } from "@/components/terminal/terminalWheelCore";
import { keyToBytes } from "@/stores/terminalKeyCore";
import type { TerminalTheme } from "@/themes/types";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { withFlagEmojiFallback } from "@/utils/emojiFont";
Expand Down Expand Up @@ -821,6 +823,64 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod
return true;
});

// Route already-encoded input bytes to the PTY, fanning out to every pane
// when split-pane broadcast is active. Shared by typed input (onData) and
// synthesized alt-screen scroll arrows so both honor broadcast identically.
const routeInputBytes = (bytes: Uint8Array) => {
const layout = useLayoutStore.getState();
const paneSessionIds = getPaneSessionIds(layout.root);
if (layout.broadcastActive && layout.splitTabActive && paneSessionIds.includes(sessionId)) {
const sessions = useSessionStore.getState().sessions;
const mpConnections = useTeamSessionStore.getState().connections;
for (const targetId of paneSessionIds) {
const target = sessions.find((s) => s.id === targetId);
if (!target || target.status !== "connected" || target.type === "multiplayer") continue;
const mpState = mpConnections[target.id];
if (mpState && mpState.controlHolder !== "" && mpState.controlHolder !== mpState.myUserId) continue;
sendSessionInput(target.id, target.type === "serial" ? "serial" : target.type as "ssh" | "local", bytes);
}
return;
}
sendSessionInput(sessionId, sessionType, bytes);
};

// Alternate-screen scroll: full-screen apps (nano/less/vim) run in the
// alternate buffer, where xterm has no scrollback to move — so a wheel or
// touchpad scroll would otherwise do nothing (#50). Translate it into Up/Down
// arrow presses, unless the app is tracking the mouse itself (e.g. vim
// `set mouse=a`), in which case xterm forwards the wheel as mouse events.
let wheelCarry = 0;
term.attachCustomWheelEventHandler((e: WheelEvent) => {
if (term.buffer.active.type !== "alternate") return true;
if (term.modes.mouseTrackingMode !== "none") return true;
// We own this event now: stop the page/ancestor from also scrolling, and
// return false so xterm skips its own (sub-pixel-dampened) translation.
e.preventDefault();
if (inputGate && !inputGate.current?.()) return false;
if (!entry.connectedRef.current) return false;

const screen = term.element?.querySelector<HTMLElement>(".xterm-screen");
const cellHeight = screen && term.rows ? screen.clientHeight / term.rows : 0;
const { rows, carry } = wheelToRows({
deltaY: e.deltaY,
deltaMode: e.deltaMode,
cellHeight,
viewportRows: term.rows,
carry: wheelCarry,
maxRows: term.rows, // one page per event caps runaway flings
});
wheelCarry = carry;
if (rows !== 0) {
const seq = keyToBytes(rows < 0 ? "Up" : "Down", {
ctrl: false,
alt: false,
appCursor: term.modes.applicationCursorKeysMode,
}).repeat(Math.abs(rows));
routeInputBytes(encoder.encode(seq));
}
return false;
});

term.open(container);

// Android: stop xterm's hidden textarea from summoning the WebView's own (broken) IME —
Expand Down Expand Up @@ -893,22 +953,7 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod
.addInput(sessionId, sess.connectionName, sess.connectionId, data);
}

const bytes = encoder.encode(data);
const layout = useLayoutStore.getState();
const paneSessionIds = getPaneSessionIds(layout.root);
if (layout.broadcastActive && layout.splitTabActive && paneSessionIds.includes(sessionId)) {
const sessions = useSessionStore.getState().sessions;
const mpConnections = useTeamSessionStore.getState().connections;
for (const targetId of paneSessionIds) {
const target = sessions.find((s) => s.id === targetId);
if (!target || target.status !== "connected" || target.type === "multiplayer") continue;
const mpState = mpConnections[target.id];
if (mpState && mpState.controlHolder !== "" && mpState.controlHolder !== mpState.myUserId) continue;
sendSessionInput(target.id, target.type === "serial" ? "serial" : target.type as "ssh" | "local", bytes);
}
return;
}
sendSessionInput(sessionId, sessionType, bytes);
routeInputBytes(encoder.encode(data));
});

const unlistenPromises: Promise<UnlistenFn>[] = [];
Expand Down