hop2 0.11.59 → 0.11.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@ import { enableMathHover } from "./utils/mathLinkProvider";
21
21
  import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
- import { claimOnAttach, claimOnClick } from "./utils/sizeClaim";
24
+ import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
25
  import { MobileKeyboard } from "./components/MobileKeyboard";
26
26
  import { SessionSwitcher } from "./components/SessionSwitcher";
27
27
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -1356,6 +1356,9 @@ const App = () => {
1356
1356
  // switch, closing the wall into the session, a click, the Fit button) —
1357
1357
  // a reconnect is the socket healing itself, not a person.
1358
1358
  const deliberateAttachRef = useRef(true);
1359
+ // Where the last press on the terminal landed, so a click can be told
1360
+ // apart from the release of a drag.
1361
+ const clickDownRef = useRef<{ x: number; y: number } | null>(null);
1359
1362
  // A canvas that was HIDDEN can come back showing a stale frame: the wall
1360
1363
  // covers the terminal with visibility:hidden, browsers throttle or drop
1361
1364
  // WebGL contexts for hidden canvases, and xterm only repaints rows it
@@ -2170,6 +2173,8 @@ const App = () => {
2170
2173
 
2171
2174
  termRef.current = terminal;
2172
2175
  fitRef.current = fitAddon;
2176
+ // Debug handle for probes and the console (selection, buffer, size).
2177
+ (window as any).__hopTerm = terminal;
2173
2178
  fitWhenReady();
2174
2179
 
2175
2180
  // Register OSC handlers for sequences xterm.js doesn't fully support
@@ -4603,15 +4608,20 @@ const App = () => {
4603
4608
  <section inert={switcherOpen} style={switcherOpen ? { visibility: "hidden" } : undefined} className={`terminal${visibleStatus === "disconnected" || visibleStatus === "ended" ? " degraded" : ""}`}>
4604
4609
  <div
4605
4610
  className="terminal-frame"
4606
- onClick={() => {
4611
+ onMouseDown={(e) => { clickDownRef.current = { x: e.clientX, y: e.clientY }; }}
4612
+ onClick={(e) => {
4607
4613
  // On mobile, don't focus terminal to prevent system keyboard
4608
4614
  if (!isMobile) {
4609
4615
  termRef.current?.focus();
4610
4616
  }
4611
4617
  // A click here is a human act on this surface, so it may
4612
4618
  // take a size some other window left behind. No-op when
4613
- // the session already fits this viewport.
4614
- claimSizeHere();
4619
+ // the session already fits this viewport — and never for
4620
+ // the click that ends a drag: that was a text selection,
4621
+ // and the resize a claim brings would wipe it.
4622
+ if (isPlainClick(clickDownRef.current, { x: e.clientX, y: e.clientY }, !!termRef.current?.hasSelection())) {
4623
+ claimSizeHere();
4624
+ }
4615
4625
  }}
4616
4626
  onDragEnter={(e) => {
4617
4627
  if (Array.from(e.dataTransfer?.types || []).includes("Files")) {
@@ -7,11 +7,11 @@ import { relativeTime } from "../utils/switcherModel";
7
7
  // this client was to catch the printed URL before it scrolled out of the
8
8
  // viewport.
9
9
  //
10
- // ONE SHAPE: a reader. Opening Views lands you IN the newest result (the
11
- // scoped session's, or the fleet's), with ‹ › to step through the rest and
12
- // an index you can pull open beside or over the page. There is no separate
13
- // "list mode" to click through first — the list was a detour on the way to
14
- // the thing you came to read.
10
+ // ONE SHAPE: a reader with its index beside it. Opening Views lands you IN
11
+ // the newest result (the scoped session's, or the fleet's), the list of
12
+ // everything published on the left, ‹ › to step through it. Both in one
13
+ // window, like a mail client — there is no separate "list mode" to click
14
+ // through first.
15
15
  //
16
16
  // Two placements of that one shape:
17
17
  //
@@ -168,11 +168,11 @@ const previewSrc = (item: ViewItem) =>
168
168
  : hrefFor(item);
169
169
 
170
170
  const RAIL_KEY = "hop_views_rail";
171
- // Wide enough to read a report AND keep the index beside it; still leaves a
172
- // usable terminal on a 1440 display. The drag handle remembers a choice.
173
- const DEFAULT_DOCK_W = 620;
171
+ // Wide enough for the index beside a readable page; on a 1440 display that
172
+ // still leaves 680px of terminal. The drag handle remembers a choice.
173
+ const DEFAULT_DOCK_W = 760;
174
174
  const clampDockW = (w: number) =>
175
- Math.max(380, Math.min(Math.round(window.innerWidth * 0.7), w));
175
+ Math.max(560, Math.min(Math.round(window.innerWidth * 0.75), w));
176
176
 
177
177
  export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Props) => {
178
178
  const [items, setItems] = useState<ViewItem[] | null>(null);
@@ -199,15 +199,13 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
199
199
  return clampDockW(Number.isFinite(stored) && stored > 0 ? stored : DEFAULT_DOCK_W);
200
200
  });
201
201
  const panelRef = useRef<HTMLDivElement | null>(null);
202
- // The index (the list of everything published) is a companion to the
203
- // reader, not a mode of its own: a sheet over the page in a narrow dock, a
204
- // rail beside it where there is room — either way only when you ask.
205
- // Hidden until asked for, everywhere: the page is what you opened Views
206
- // to see. Beside the page it stays as you left it (remembered).
202
+ // The index (the list of everything published) sits BESIDE the page, in
203
+ // the same window, like a mail client: the list on the left, the result on
204
+ // the right, both visible at once. ☰ hides it for a wider page, and that
205
+ // choice is remembered.
207
206
  const [railOpen, setRailOpen] = useState<boolean>(() => {
208
- try { return localStorage.getItem(RAIL_KEY) === "1"; } catch { return false; }
207
+ try { return localStorage.getItem(RAIL_KEY) !== "0"; } catch { return true; }
209
208
  });
210
- const [indexOpen, setIndexOpen] = useState(false);
211
209
  const now = Date.now();
212
210
 
213
211
  // The scope arrives as whatever identifier the opener HAD — the URL's
@@ -312,10 +310,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
312
310
  if (event.key === "Escape") {
313
311
  event.preventDefault();
314
312
  event.stopPropagation();
315
- // The index OVER the page is the one layer to back out of; the
316
- // reader itself is the panel, so the next Escape closes it.
317
- if (railOverlay && indexOpen) setIndexOpen(false);
318
- else onClose();
313
+ // The reader IS the panel: Escape closes it.
314
+ onClose();
319
315
  return;
320
316
  }
321
317
  const dir = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1
@@ -329,7 +325,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
329
325
  window.addEventListener("keydown", onKey, true);
330
326
  return () => window.removeEventListener("keydown", onKey, true);
331
327
  // eslint-disable-next-line react-hooks/exhaustive-deps
332
- }, [onClose, preview, flatRows, docked, indexOpen, dockW]);
328
+ }, [onClose, preview, flatRows, docked]);
333
329
 
334
330
  // Opening the panel IS the act of seeing what it shows.
335
331
  useEffect(() => {
@@ -366,7 +362,6 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
366
362
  if (!canPreview()) return; // narrow: the anchor's new tab is right
367
363
  event.preventDefault();
368
364
  setPreview(item);
369
- setIndexOpen(false);
370
365
  };
371
366
 
372
367
  const copyLink = (item: ViewItem) => {
@@ -434,13 +429,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
434
429
  el.addEventListener("pointerup", onUp);
435
430
  };
436
431
 
437
- // Where the index lives: beside the page when the panel is wide enough for
438
- // both, over it otherwise (a 620px dock with a 240px rail leaves 380px of
439
- // page — enough for a plot, not for a report).
440
- const railOverlay = docked ? dockW < 900 : false;
441
- const railVisible = (railOverlay ? indexOpen : railOpen) && flatRows.length > 0;
432
+ const railVisible = railOpen && flatRows.length > 0;
442
433
  const toggleRail = () => {
443
- if (railOverlay) { setIndexOpen((v) => !v); return; }
444
434
  setRailOpen((v) => {
445
435
  try { localStorage.setItem(RAIL_KEY, v ? "0" : "1"); } catch { /* private mode */ }
446
436
  return !v;
@@ -586,7 +576,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
586
576
  <div className="views-nav">
587
577
  <button type="button" className={"views-act views-rail-toggle" + (railVisible ? " on" : "")}
588
578
  aria-label={railVisible ? "Hide the index" : "Show the index"} aria-pressed={railVisible}
589
- title={railVisible ? "Hide the index" : "Everything published"}
579
+ title={railVisible ? "Hide the index (a wider page)" : "Show the index beside the page"}
590
580
  onClick={toggleRail}>☰</button>
591
581
  <button type="button" className="views-act" aria-label="Previous view" title="Previous (←)"
592
582
  disabled={at <= 0} onClick={() => step(-1)}>‹</button>
@@ -638,7 +628,7 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
638
628
  {!docked && <div className="views-backdrop" onClick={onClose} />}
639
629
  <div className={"views-panel" + (docked ? " docked" : "")
640
630
  + (flatRows.length > 0 ? " reading" : "")
641
- + (railVisible ? (railOverlay ? " rail-over" : " rail-side") : "")}
631
+ + (railVisible ? " rail-side" : "")}
642
632
  style={docked ? { width: dockW } : undefined}
643
633
  role="dialog" aria-label="Published views"
644
634
  ref={panelRef} tabIndex={-1}>
@@ -673,15 +663,10 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
673
663
  </div>
674
664
  {nav}
675
665
  <div className="views-body">
676
- {railVisible && !railOverlay && list}
666
+ {railVisible && list}
677
667
  <div className="views-preview">
678
668
  {empty || (canPreview() ? page : list)}
679
669
  </div>
680
- {railVisible && railOverlay && (
681
- <div className="views-index" role="region" aria-label="Everything published">
682
- {list}
683
- </div>
684
- )}
685
670
  </div>
686
671
  </div>
687
672
  </>
@@ -4511,17 +4511,12 @@ body.pane-dragging { user-select: none; }
4511
4511
  padding-right: 10px;
4512
4512
  margin-right: 12px;
4513
4513
  }
4514
- .views-panel.docked.rail-side .views-list { flex-basis: 240px; }
4515
- /* The index OVER the page (a narrow dock): a sheet under the nav bar. */
4516
- .views-index {
4517
- position: absolute; inset: 0; z-index: 2;
4518
- display: flex; flex-direction: column;
4519
- background: var(--panel);
4520
- border: 1px solid var(--line); border-radius: 10px;
4521
- padding: 8px 10px;
4522
- box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
4523
- }
4524
- .views-index .views-list { flex: 1; }
4514
+ .views-panel.docked.rail-side .views-list { flex-basis: 232px; }
4515
+ /* In the rail the title owns the row: the per-row actions would crowd it
4516
+ to three letters, and the reader's bar carries the same actions for the
4517
+ page that is open. (Still in the DOM — a screen reader finds them.) */
4518
+ .views-panel.rail-side .views-row .views-acts { display: none; }
4519
+ .views-panel.rail-side .views-row.selected { background: color-mix(in srgb, var(--accent, #7c5cff) 16%, transparent); }
4525
4520
  .views-preview { flex: 1; min-width: 0; display: flex; flex-direction: column; }
4526
4521
  /* The reader's bar: index toggle · ‹ n/N › · title · actions. */
4527
4522
  .views-nav {
@@ -39,3 +39,20 @@ export const claimOnClick = (o: {
39
39
  if (!o.active) return true;
40
40
  return o.active.cols !== o.natural.cols || o.active.rows !== o.natural.rows;
41
41
  };
42
+
43
+ /**
44
+ * A click is a click only when the pointer stayed put between press and
45
+ * release and nothing is selected. A drag that ends over the terminal also
46
+ * fires `click` — and that drag was a text selection, which a size claim
47
+ * (a resize) would wipe the moment it finished.
48
+ */
49
+ export const isPlainClick = (
50
+ down: { x: number; y: number } | null,
51
+ up: { x: number; y: number },
52
+ hasSelection: boolean,
53
+ slopPx = 4
54
+ ): boolean => {
55
+ if (hasSelection) return false;
56
+ if (!down) return true;
57
+ return Math.hypot(up.x - down.x, up.y - down.y) <= slopPx;
58
+ };
@@ -16,10 +16,7 @@ let store: Record<string, string> = {};
16
16
  beforeEach(() => {
17
17
  // jsdom's own localStorage is only partially implemented here, and the
18
18
  // seen-markers are the whole point of these cases.
19
- // The index rail is hidden until asked and the choice is remembered; most
20
- // cases below read rows from it, so they start with it open, the way a
21
- // reader who opened it once would. The reader-first case clears this.
22
- store = { hop_views_rail: "1" };
19
+ store = {};
23
20
  vi.stubGlobal("localStorage", {
24
21
  getItem: (key: string) => (key in store ? store[key] : null),
25
22
  setItem: (key: string, value: string) => { store[key] = value; },
@@ -60,7 +57,6 @@ describe("ViewsPanel", () => {
60
57
 
61
58
  it("opens STRAIGHT INTO the newest result; a row click switches the page; the row stays a real link", async () => {
62
59
  const onClose = vi.fn();
63
- delete store.hop_views_rail; // a first-time reader: nothing remembered
64
60
  render(<ViewsPanel onClose={onClose} />);
65
61
  await waitFor(() => expect(document.querySelector("iframe.views-frame")).toBeTruthy());
66
62
  // jsdom's window is 1024 wide — the desk case, where the page exists.
@@ -74,10 +70,9 @@ describe("ViewsPanel", () => {
74
70
  // browser's PDF viewer, which is half of what the pane is for.
75
71
  expect(frame.hasAttribute("sandbox")).toBe(false);
76
72
  expect(screen.getByText("1 / 3")).toBeTruthy();
77
- // The index is not shown until asked — the page is the point. ☰ opens
78
- // it beside the page; a plain click on a row turns the page.
79
- expect(document.querySelector(".views-list")).toBeNull();
80
- fireEvent.click(screen.getByRole("button", { name: "Show the index" }));
73
+ // The index is beside the page from the start — both in one window; a
74
+ // plain click on a row turns the page.
75
+ expect(document.querySelector(".views-panel.rail-side .views-list")).toBeTruthy();
81
76
  const row = screen.getByText("views-test.html").closest("a") as HTMLAnchorElement;
82
77
  expect(row.getAttribute("target")).toBe("_blank");
83
78
  fireEvent.click(row);
@@ -95,7 +90,7 @@ describe("ViewsPanel", () => {
95
90
  expect(onClose).toHaveBeenCalled();
96
91
  });
97
92
 
98
- it("docked: the page is open at once, the index rides OVER it on demand, and keys are focus-scoped", async () => {
93
+ it("docked: the page is open at once with the index beside it, and keys are focus-scoped", async () => {
99
94
  // The dock only exists on wide windows; jsdom defaults to 1024.
100
95
  vi.stubGlobal("innerWidth", 1280);
101
96
  const onClose = vi.fn();
@@ -118,14 +113,14 @@ describe("ViewsPanel", () => {
118
113
  fireEvent.keyDown(window, { key: "Escape" });
119
114
  expect(onClose).not.toHaveBeenCalled();
120
115
 
121
- // A 620px dock has no room for the index beside the page: ☰ lays it
122
- // OVER the page, and Esc from the panel backs that one layer out.
123
- fireEvent.click(screen.getByRole("button", { name: "Show the index" }));
124
- expect(document.querySelector(".views-index .views-list")).toBeTruthy();
125
- expect(document.querySelector("iframe.views-frame")).toBeTruthy(); // the page stays under it
126
- fireEvent.keyDown(panel, { key: "Escape" });
127
- expect(document.querySelector(".views-index")).toBeNull();
128
- // The next Esc from the panel closes it.
116
+ // The index sits beside the page in the dock too; ☰ hides it for a
117
+ // wider page and the choice is remembered.
118
+ expect(document.querySelector(".views-panel.rail-side .views-list")).toBeTruthy();
119
+ fireEvent.click(screen.getByRole("button", { name: "Hide the index" }));
120
+ expect(document.querySelector(".views-list")).toBeNull();
121
+ expect(store.hop_views_rail).toBe("0");
122
+ expect(document.querySelector("iframe.views-frame")).toBeTruthy();
123
+ // Esc from the panel closes it.
129
124
  fireEvent.keyDown(panel, { key: "Escape" });
130
125
  expect(onClose).toHaveBeenCalled();
131
126
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { claimOnAttach, claimOnClick } from "../src/utils/sizeClaim";
2
+ import { claimOnAttach, claimOnClick, isPlainClick } from "../src/utils/sizeClaim";
3
3
 
4
4
  describe("claimOnAttach — only a deliberate open takes the size", () => {
5
5
  const base = { viewMode: "fit" as const, visible: true, deliberate: true };
@@ -29,3 +29,14 @@ describe("claimOnClick — a click takes the size only when it changes something
29
29
  expect(claimOnClick({ viewMode: "fit", natural: null, active: null, owned: false })).toBe(false);
30
30
  });
31
31
  });
32
+
33
+ describe("isPlainClick — the release of a drag is not a click", () => {
34
+ it("a press and release in the same place, nothing selected, is a click", () => {
35
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 102, y: 101 }, false)).toBe(true);
36
+ expect(isPlainClick(null, { x: 5, y: 5 }, false)).toBe(true);
37
+ });
38
+ it("a drag, or any release with a selection standing, is not", () => {
39
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 260, y: 100 }, false)).toBe(false);
40
+ expect(isPlainClick({ x: 100, y: 100 }, { x: 100, y: 100 }, true)).toBe(false);
41
+ });
42
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.59",
3
+ "version": "0.11.61",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {