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
|
-
|
|
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
|
|
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
|
-
|
|
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