Files
Triple-C/app/src/store/appState.test.ts
T
shadowdaoandClaude Opus 5 f7db4323be Make the drop gate a state question, not a geometry one
The gate that decides whether a native file drop is accepted has been wrong
twice in opposite directions, both times because it tried to be precise about
*which points* a dialog covers:

- Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner
  xterm host while the overlays are siblings, so the always-rendered
  Following/Paused button made the terminal's top-right corner permanently
  refuse drops.
- Round 2 replaced that with "is a blocking overlay painted here?" and deleted
  the document-wide gate. `elementFromPoint` returns the *topmost* element, and
  ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking
  context — so a refused drop pushed a toast, the toast covered the dialog, and
  the next drop released on it was reported clear and landed in the directory
  the dialog was covering. The gate armed its own hole.

Split the two questions instead of merging them:

- Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly
  one listener speaks for a drop and a hidden pane's zero-size rect still keeps
  TerminalView and FilesTab from both firing.
- `dropIsBlocked` answers whether the app should take a drop at all —
  document-wide, no z-index in it. While a modal or blocking overlay is on
  screen anywhere, every drop is refused.

There is no `elementFromPoint` call left, so no future overlay can become a
drop hole by being painted high enough and no chrome can become a dead zone by
being painted at all. The cost is over-refusal while a dialog is open, in a
state the user entered deliberately, announced, writing nothing.

Also:
- `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a
  visibility statement (it sits on visible decorative content), so a blocker
  nested in such a wrapper would have silently stopped blocking.
- Modal drops `data-blocks-drop` when its pane hides, and moves focus out of
  itself rather than leaving it inside a `display:none` panel.
- The refusal notice stays `kind: "info"` (an expected refusal is not an
  error, and an error card never auto-dismisses) and carries a `dedupeKey`, so
  repeated refusals replace rather than stack.

Tests: mutation-checked against the previous implementation — four in
dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 15:31:13 -07:00

115 lines
3.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
const A = homeTabKey("a");
const B = terminalTabKey("b");
const C = terminalTabKey("c");
function seed(tabOrder: string[], activeTabKey: string | null = null) {
useAppState.setState({
tabOrder,
activeTabKey,
activeSessionId: null,
selectedProjectId: null,
});
}
const order = () => useAppState.getState().tabOrder;
describe("tab reordering", () => {
beforeEach(() => seed([A, B, C]));
it("moves a tab to an earlier slot", () => {
useAppState.getState().moveTab(C, 0);
expect(order()).toEqual([C, A, B]);
});
it("moves a tab to a later slot", () => {
useAppState.getState().moveTab(A, 2);
expect(order()).toEqual([B, C, A]);
});
it("clamps a destination past the ends rather than dropping the tab", () => {
useAppState.getState().moveTab(A, 99);
expect(order()).toEqual([B, C, A]);
useAppState.getState().moveTab(A, -5);
expect(order()).toEqual([A, B, C]);
});
it("ignores a tab that isn't in the strip", () => {
useAppState.getState().moveTab("term:gone", 0);
expect(order()).toEqual([A, B, C]);
});
it("does not change what's active — dragging a tab is not selecting it", () => {
seed([A, B, C], B);
useAppState.getState().moveTab(C, 0);
const state = useAppState.getState();
expect(state.tabOrder).toEqual([C, A, B]);
expect(state.activeTabKey).toBe(B);
});
it("nudges the active tab with the keyboard, in both directions", () => {
seed([A, B, C], B);
useAppState.getState().moveActiveTab(-1);
expect(order()).toEqual([B, A, C]);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("stops the active tab at the ends instead of wrapping it around", () => {
seed([A, B, C], A);
useAppState.getState().moveActiveTab(-1);
// A held-down key must not teleport the tab to the far end.
expect(order()).toEqual([A, B, C]);
});
it("does nothing when no tab is active", () => {
seed([A, B, C], null);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("keeps Ctrl+1..9 addressing the strip as reordered", () => {
seed([A, B, C], A);
useAppState.getState().moveTab(C, 0);
useAppState.getState().focusTabIndex(0);
expect(useAppState.getState().activeTabKey).toBe(C);
});
});
describe("toasts", () => {
beforeEach(() => useAppState.setState({ toasts: [] }));
const toasts = () => useAppState.getState().toasts;
it("stacks unkeyed toasts, which are one-off reports", () => {
const { pushToast } = useAppState.getState();
pushToast({ kind: "error", message: "one" });
pushToast({ kind: "error", message: "two" });
expect(toasts().map((t) => t.message)).toEqual(["one", "two"]);
});
it("lets a keyed toast supersede the one already on screen", () => {
// A recurring notice — "File drop ignored" is the one that exists — must
// not build a wall of identical cards when the user tries three times.
const { pushToast } = useAppState.getState();
const first = pushToast({ kind: "info", message: "ignored", dedupeKey: "drop-blocked" });
pushToast({ kind: "error", message: "unrelated" });
const second = pushToast({ kind: "info", message: "ignored", dedupeKey: "drop-blocked" });
expect(toasts().map((t) => t.message)).toEqual(["unrelated", "ignored"]);
// A fresh id, so the card re-mounts and its dismissal timer restarts
// rather than the replacement inheriting the first one's remaining time.
expect(second).not.toBe(first);
expect(toasts().some((t) => t.id === first)).toBe(false);
});
it("keeps toasts with different keys apart", () => {
const { pushToast } = useAppState.getState();
pushToast({ kind: "info", message: "a", dedupeKey: "x" });
pushToast({ kind: "info", message: "b", dedupeKey: "y" });
expect(toasts()).toHaveLength(2);
});
});