hop2 0.11.42 → 0.11.44

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.
@@ -12,6 +12,7 @@ import {
12
12
  } from "react";
13
13
  import { Terminal } from "@xterm/xterm";
14
14
  import { FitAddon } from "@xterm/addon-fit";
15
+ import { WebLinksAddon } from "@xterm/addon-web-links";
15
16
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
16
17
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
17
18
  import { CwdField } from "./CwdField";
@@ -473,6 +474,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
473
474
  });
474
475
  const fit = new FitAddon();
475
476
  term.loadAddon(fit);
477
+ // Same as the full-screen terminal: URLs are links (line-wrapped OAuth
478
+ // and codex sign-in URLs included). The tile is where most reading
479
+ // happens now, and a URL you could click full-screen but not here read
480
+ // as "links don't work in hop". window.open inside the click handler
481
+ // keeps the popup-blocker happy; the tile's own click handler already
482
+ // stops the card from treating the click as a switch.
483
+ term.loadAddon(new WebLinksAddon((event, uri) => {
484
+ event.preventDefault();
485
+ window.open(uri, "_blank", "noopener");
486
+ }));
476
487
  term.open(box);
477
488
  termRef.current = term;
478
489
  fitRef.current = fit;
@@ -1636,7 +1647,18 @@ export const SessionSwitcher = ({
1636
1647
  // sessions that vanish drop out; brand-new ones append at the end rather
1637
1648
  // than reshuffling the wall.
1638
1649
  const frozenOrderRef = useRef<{ mode: "tiers" | "project"; hero: string[]; groups: Array<{ label: string; rows: string[] }> } | null>(null);
1639
- useEffect(() => { if (!open) frozenOrderRef.current = null; }, [open]);
1650
+ // Search results freeze the same way, per QUERY: the order you see is the
1651
+ // order at the moment you searched. Score ties break on attention and
1652
+ // recency, and manual mode's unplaced rows sort by name, so without this a
1653
+ // bell, an activity tick, a rename, or a fresh tagline re-sorted the
1654
+ // results under the cursor. New matches join at the end; matches that
1655
+ // stop matching drop; a new query, or the user's own drag, recaptures.
1656
+ const frozenQueryRef = useRef<
1657
+ | { mode: "filter"; query: string; manualOrder: string[]; rows: string[] }
1658
+ | { mode: "manual"; query: string; manualOrder: string[]; loose: string[]; folders: Array<{ id: string; rows: string[] }> }
1659
+ | null
1660
+ >(null);
1661
+ useEffect(() => { if (!open) { frozenOrderRef.current = null; frozenQueryRef.current = null; } }, [open]);
1640
1662
  // A non-empty query, whichever render branch the model picks for it.
1641
1663
  const searching = filter.trim().length > 0;
1642
1664
  const model = useMemo(() => {
@@ -1644,7 +1666,48 @@ export const SessionSwitcher = ({
1644
1666
  // wall shows only unparked sessions.
1645
1667
  const pool = filter.trim() ? visibleSessions : wallSessions;
1646
1668
  const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder, folders);
1647
- if (!open || (live.mode !== "tiers" && live.mode !== "project")) return live;
1669
+ if (!open) return live;
1670
+ const query = filter.trim();
1671
+ if (live.mode === "filter" || (live.mode === "manual" && query)) {
1672
+ const fq = frozenQueryRef.current;
1673
+ const stale = !fq || fq.mode !== live.mode || fq.query !== query || fq.manualOrder !== manualOrder;
1674
+ if (stale) {
1675
+ frozenQueryRef.current = live.mode === "filter"
1676
+ ? { mode: "filter", query, manualOrder, rows: live.rows.map(sessionKey) }
1677
+ : {
1678
+ mode: "manual", query, manualOrder,
1679
+ loose: live.rows.map(sessionKey),
1680
+ folders: live.folders.map((f) => ({ id: f.folder.id, rows: f.rows.map(sessionKey) }))
1681
+ };
1682
+ return live;
1683
+ }
1684
+ // Resolve against what still MATCHES (the live rows), never the whole
1685
+ // pool: a session that stopped matching leaves, one that started
1686
+ // matching joins at the end, and nothing in between moves.
1687
+ const pin = (frozenKeys: string[], liveRows: SwitcherSession[]) => {
1688
+ const byKey = new Map(liveRows.map((r) => [sessionKey(r), r]));
1689
+ const known = new Set(frozenKeys);
1690
+ return [
1691
+ ...frozenKeys.map((k) => byKey.get(k)).filter((r): r is SwitcherSession => !!r),
1692
+ ...liveRows.filter((r) => !known.has(sessionKey(r)))
1693
+ ];
1694
+ };
1695
+ if (live.mode === "filter" && fq.mode === "filter") {
1696
+ return { mode: "filter" as const, rows: pin(fq.rows, live.rows) };
1697
+ }
1698
+ if (live.mode === "manual" && fq.mode === "manual") {
1699
+ return {
1700
+ mode: "manual" as const,
1701
+ rows: pin(fq.loose, live.rows),
1702
+ folders: live.folders.map((f) => {
1703
+ const fz = fq.folders.find((x) => x.id === f.folder.id);
1704
+ return fz ? { folder: f.folder, rows: pin(fz.rows, f.rows) } : f;
1705
+ })
1706
+ };
1707
+ }
1708
+ return live;
1709
+ }
1710
+ if (live.mode !== "tiers" && live.mode !== "project") return live;
1648
1711
  let frozen = frozenOrderRef.current;
1649
1712
  if (!frozen || frozen.mode !== live.mode) {
1650
1713
  frozen = live.mode === "tiers"
@@ -247,7 +247,11 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
247
247
  const onKey = (event: KeyboardEvent) => {
248
248
  if (docked) {
249
249
  const el = panelRef.current;
250
- const target = event.target as Node | null;
250
+ // A keydown dispatched on `window` (tests do this; so does a page with
251
+ // nothing focused) has a target that is not a Node, and jsdom throws
252
+ // on contains() for it — which took the whole test run down as an
253
+ // unhandled error. Only a Node can be inside the panel.
254
+ const target = event.target instanceof Node ? event.target : null;
251
255
  const inside = !!el && ((target && el.contains(target))
252
256
  || (document.activeElement && el.contains(document.activeElement)));
253
257
  if (!inside) return;
@@ -307,6 +307,50 @@ describe("context menus", () => {
307
307
  });
308
308
  });
309
309
 
310
+ describe("search results hold their order", () => {
311
+ afterEach(() => { document.body.innerHTML = ""; localStorage.removeItem("hay_sort_mode"); });
312
+ const mk = (name: string, activity: number, extra: Partial<SwitcherSession> = {}): SwitcherSession =>
313
+ ({ name, displayName: name, internalName: name, active: true, starting: false, createdBy: "user", lastActivityAt: activity, ...extra });
314
+ const order = () => Array.from(document.querySelectorAll(".switcher-card[data-session-key]")).map((c) => c.getAttribute("data-session-key"));
315
+ const typeQuery = (q: string) => {
316
+ const input = document.querySelector(".switcher-top input") as HTMLInputElement;
317
+ fireEvent.change(input, { target: { value: q } });
318
+ };
319
+ // Both names match "vault" as a prefix with equal score, so the tie-break
320
+ // (attention, then recency) decides — which is exactly what must not keep
321
+ // deciding after the search.
322
+ const two = (a: Partial<SwitcherSession> = {}, b: Partial<SwitcherSession> = {}) =>
323
+ [mk("vault-a", 1000, a), mk("vault-b", 2000, b)];
324
+
325
+ it("in Recent mode: activity, bells and new matches do not reorder the results you searched", () => {
326
+ localStorage.setItem("hay_sort_mode", "recent");
327
+ const view = render(<SessionSwitcher {...props} sessions={two()} open />);
328
+ typeQuery("vault");
329
+ expect(order()).toEqual(["vault-b", "vault-a"]); // recency ranks b first — at search time
330
+ // a becomes the most active and rings a bell: it would now rank first.
331
+ view.rerender(<SessionSwitcher {...props} sessions={two({ lastActivityAt: 99000, bellUnseen: true })} open />);
332
+ expect(order()).toEqual(["vault-b", "vault-a"]);
333
+ // A new match joins at the end; one that stops matching leaves.
334
+ view.rerender(<SessionSwitcher {...props} sessions={[...two({ lastActivityAt: 99000 }), mk("vault-c", 500000)]} open />);
335
+ expect(order()).toEqual(["vault-b", "vault-a", "vault-c"]);
336
+ view.rerender(<SessionSwitcher {...props} sessions={[mk("vault-a", 99000), mk("vault-c", 500000)]} open />);
337
+ expect(order()).toEqual(["vault-a", "vault-c"]);
338
+ // A new query is a new search: ranked fresh.
339
+ typeQuery("vault-c");
340
+ expect(order()).toEqual(["vault-c"]);
341
+ });
342
+
343
+ it("in Manual mode: a rename mid-search does not move the card", () => {
344
+ localStorage.setItem("hay_sort_mode", "manual");
345
+ const view = render(<SessionSwitcher {...props} sessions={two()} open />);
346
+ typeQuery("vault");
347
+ expect(order()).toEqual(["vault-a", "vault-b"]); // unplaced rows: by name at search time
348
+ // a is renamed to something that sorts after b (and still matches).
349
+ view.rerender(<SessionSwitcher {...props} sessions={two({ displayName: "zz-vault-a" })} open />);
350
+ expect(order()).toEqual(["vault-a", "vault-b"]);
351
+ });
352
+ });
353
+
310
354
  describe("manual folders in the wall", () => {
311
355
  afterEach(() => { document.body.innerHTML = ""; });
312
356
  const folders = [{ id: "f1", name: "Filed" }];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.42",
3
+ "version": "0.11.44",
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": {