hop2 0.11.41 → 0.11.43
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.
|
@@ -1636,7 +1636,18 @@ export const SessionSwitcher = ({
|
|
|
1636
1636
|
// sessions that vanish drop out; brand-new ones append at the end rather
|
|
1637
1637
|
// than reshuffling the wall.
|
|
1638
1638
|
const frozenOrderRef = useRef<{ mode: "tiers" | "project"; hero: string[]; groups: Array<{ label: string; rows: string[] }> } | null>(null);
|
|
1639
|
-
|
|
1639
|
+
// Search results freeze the same way, per QUERY: the order you see is the
|
|
1640
|
+
// order at the moment you searched. Score ties break on attention and
|
|
1641
|
+
// recency, and manual mode's unplaced rows sort by name, so without this a
|
|
1642
|
+
// bell, an activity tick, a rename, or a fresh tagline re-sorted the
|
|
1643
|
+
// results under the cursor. New matches join at the end; matches that
|
|
1644
|
+
// stop matching drop; a new query, or the user's own drag, recaptures.
|
|
1645
|
+
const frozenQueryRef = useRef<
|
|
1646
|
+
| { mode: "filter"; query: string; manualOrder: string[]; rows: string[] }
|
|
1647
|
+
| { mode: "manual"; query: string; manualOrder: string[]; loose: string[]; folders: Array<{ id: string; rows: string[] }> }
|
|
1648
|
+
| null
|
|
1649
|
+
>(null);
|
|
1650
|
+
useEffect(() => { if (!open) { frozenOrderRef.current = null; frozenQueryRef.current = null; } }, [open]);
|
|
1640
1651
|
// A non-empty query, whichever render branch the model picks for it.
|
|
1641
1652
|
const searching = filter.trim().length > 0;
|
|
1642
1653
|
const model = useMemo(() => {
|
|
@@ -1644,7 +1655,48 @@ export const SessionSwitcher = ({
|
|
|
1644
1655
|
// wall shows only unparked sessions.
|
|
1645
1656
|
const pool = filter.trim() ? visibleSessions : wallSessions;
|
|
1646
1657
|
const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder, folders);
|
|
1647
|
-
if (!open
|
|
1658
|
+
if (!open) return live;
|
|
1659
|
+
const query = filter.trim();
|
|
1660
|
+
if (live.mode === "filter" || (live.mode === "manual" && query)) {
|
|
1661
|
+
const fq = frozenQueryRef.current;
|
|
1662
|
+
const stale = !fq || fq.mode !== live.mode || fq.query !== query || fq.manualOrder !== manualOrder;
|
|
1663
|
+
if (stale) {
|
|
1664
|
+
frozenQueryRef.current = live.mode === "filter"
|
|
1665
|
+
? { mode: "filter", query, manualOrder, rows: live.rows.map(sessionKey) }
|
|
1666
|
+
: {
|
|
1667
|
+
mode: "manual", query, manualOrder,
|
|
1668
|
+
loose: live.rows.map(sessionKey),
|
|
1669
|
+
folders: live.folders.map((f) => ({ id: f.folder.id, rows: f.rows.map(sessionKey) }))
|
|
1670
|
+
};
|
|
1671
|
+
return live;
|
|
1672
|
+
}
|
|
1673
|
+
// Resolve against what still MATCHES (the live rows), never the whole
|
|
1674
|
+
// pool: a session that stopped matching leaves, one that started
|
|
1675
|
+
// matching joins at the end, and nothing in between moves.
|
|
1676
|
+
const pin = (frozenKeys: string[], liveRows: SwitcherSession[]) => {
|
|
1677
|
+
const byKey = new Map(liveRows.map((r) => [sessionKey(r), r]));
|
|
1678
|
+
const known = new Set(frozenKeys);
|
|
1679
|
+
return [
|
|
1680
|
+
...frozenKeys.map((k) => byKey.get(k)).filter((r): r is SwitcherSession => !!r),
|
|
1681
|
+
...liveRows.filter((r) => !known.has(sessionKey(r)))
|
|
1682
|
+
];
|
|
1683
|
+
};
|
|
1684
|
+
if (live.mode === "filter" && fq.mode === "filter") {
|
|
1685
|
+
return { mode: "filter" as const, rows: pin(fq.rows, live.rows) };
|
|
1686
|
+
}
|
|
1687
|
+
if (live.mode === "manual" && fq.mode === "manual") {
|
|
1688
|
+
return {
|
|
1689
|
+
mode: "manual" as const,
|
|
1690
|
+
rows: pin(fq.loose, live.rows),
|
|
1691
|
+
folders: live.folders.map((f) => {
|
|
1692
|
+
const fz = fq.folders.find((x) => x.id === f.folder.id);
|
|
1693
|
+
return fz ? { folder: f.folder, rows: pin(fz.rows, f.rows) } : f;
|
|
1694
|
+
})
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
return live;
|
|
1698
|
+
}
|
|
1699
|
+
if (live.mode !== "tiers" && live.mode !== "project") return live;
|
|
1648
1700
|
let frozen = frozenOrderRef.current;
|
|
1649
1701
|
if (!frozen || frozen.mode !== live.mode) {
|
|
1650
1702
|
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