diff --git a/src/ui/hooks/use-action-slot.ts b/src/ui/hooks/use-action-slot.ts index e450c345d..54fd9d35e 100644 --- a/src/ui/hooks/use-action-slot.ts +++ b/src/ui/hooks/use-action-slot.ts @@ -52,6 +52,10 @@ function contextKey(context: Record | undefined): string { try { return JSON.stringify(context); } catch { - return String(Object.keys(context)); + // A value that will not serialize still counts as there or not, so a + // handle filling in changes the key. + return Object.entries(context) + .map(([key, value]) => `${key}:${value == null ? "-" : typeof value}`) + .join(","); } } diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index 02e7df5ff..82c38269d 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -33,6 +33,7 @@ import { isSessionTabType } from "@/shell/tab-registry"; import { isElectron } from "@/lib/electron"; import type { Tab, TabType } from "@/types/ui-types"; import { ActionSlot } from "@/shell/ActionSlot"; +import { useTabHandlesVersion } from "@/shell/tab-handles"; import { canJoinSplit, isSplitTab, @@ -97,6 +98,8 @@ export function TabBar({ showTabNumbers?: boolean; }) { const { t } = useTranslation(); + // Re-renders when a tab's handle attaches, for buttons whose when reads it. + useTabHandlesVersion(); const reduceMotion = useReducedMotion(); const [open, setOpen] = useState(true); const [dragTabId, setDragTabId] = useState(null); diff --git a/src/ui/shell/tab-handles.ts b/src/ui/shell/tab-handles.ts new file mode 100644 index 000000000..b679858e0 --- /dev/null +++ b/src/ui/shell/tab-handles.ts @@ -0,0 +1,45 @@ +import { useSyncExternalStore, type Ref, type RefObject } from "react"; + +/** + * Lets the tab bar know when a tab's surface handle attaches. A ref filling + * in renders nothing on its own, so a tab button whose `when` reads the + * handle (like Share) would wait for some unrelated render to show up. + */ +let version = 0; +const listeners = new Set<() => void>(); +const callbacks = new WeakMap, (value: unknown) => void>(); + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * A callback ref that fills `ref` and tells the tab bar. The same function + * comes back for the same ref, so React never detaches and reattaches it. + */ +export function trackTabHandle( + ref: RefObject | undefined, +): Ref | undefined { + if (!ref) return undefined; + let callback = callbacks.get(ref); + if (!callback) { + callback = (value: unknown) => { + if (ref.current === value) return; + (ref as { current: unknown }).current = value; + version += 1; + for (const listener of listeners) listener(); + }; + callbacks.set(ref, callback); + } + return callback; +} + +/** Changes whenever any tab's handle attaches or goes away. */ +export function useTabHandlesVersion(): number { + return useSyncExternalStore( + subscribe, + () => version, + () => version, + ); +} diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 0ba1d3982..027ffdc01 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -14,6 +14,7 @@ import { import { lazy, memo, Suspense } from "react"; import { useTranslation } from "react-i18next"; import type { Tab, TabType } from "@/types/ui-types"; +import { trackTabHandle } from "@/shell/tab-handles"; import { hostToSSHHost } from "@/lib/host-to-ssh-host"; import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; import { @@ -164,7 +165,9 @@ const RegisteredTab = memo(function RegisteredTab({ isVisible={isVisible} isFocusedPane={isFocusedPane} inSplit={inSplit} - handleRef={tab.terminalRef as React.Ref} + handleRef={trackTabHandle( + tab.terminalRef as React.RefObject | undefined, + )} shell={shell} /> ); diff --git a/src/ui/tests/shell/tab-handles.test.tsx b/src/ui/tests/shell/tab-handles.test.tsx new file mode 100644 index 000000000..3b55ba52f --- /dev/null +++ b/src/ui/tests/shell/tab-handles.test.tsx @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + act, + cleanup, + render, + renderHook, + screen, +} from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { createRef } from "react"; +import { Share2 } from "lucide-react"; +import type { Tab } from "@/types/ui-types"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/electron", () => ({ isElectron: () => true })); +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => ({ + permissions: [], + isAdmin: false, + loaded: true, + has: () => true, + }), +})); + +import { trackTabHandle, useTabHandlesVersion } from "@/shell/tab-handles"; +import { TabBar } from "@/shell/TabBar"; +import { + declareActionSlot, + registerAction, + registerSlotContribution, + resetActionRegistry, +} from "@/shell/action-registry"; + +afterEach(() => { + cleanup(); + resetActionRegistry(); +}); + +describe("trackTabHandle", () => { + it("passes no ref through", () => { + expect(trackTabHandle(undefined)).toBeUndefined(); + }); + + it("returns one callback per ref that fills it and bumps the version", () => { + const ref = createRef(); + const callback = trackTabHandle(ref) as (value: unknown) => void; + expect(trackTabHandle(ref)).toBe(callback); + + const { result } = renderHook(() => useTabHandlesVersion()); + const before = result.current; + const handle = { disconnect: () => {} }; + act(() => callback(handle)); + expect(ref.current).toBe(handle); + expect(result.current).toBe(before + 1); + + act(() => callback(handle)); + expect(result.current).toBe(before + 1); + + act(() => callback(null)); + expect(ref.current).toBeNull(); + expect(result.current).toBe(before + 2); + }); +}); + +describe("tab inline buttons", () => { + it("show as soon as the tab's handle attaches", () => { + declareActionSlot({ id: "tab.inline", accepts: ["button"] }); + registerAction("test.share", () => {}); + registerSlotContribution("tab.inline", { + actionId: "test.share", + titleKey: "share", + icon: Share2, + when: (context) => + typeof (context.handle as { getShareTarget?: unknown } | undefined) + ?.getShareTarget === "function", + }); + const terminalRef = createRef(); + const tabs = [ + { id: "terminal-1", type: "terminal", label: "web-01", terminalRef }, + ] as unknown as Tab[]; + + render( + {}} + onCloseTab={() => {}} + onRefreshTab={() => {}} + onReorderTabs={() => {}} + onSplitAction={() => {}} + isAppFullscreen={false} + onToggleAppFullscreen={() => {}} + />, + ); + expect(screen.queryByTitle("share")).toBeNull(); + + const attach = trackTabHandle(terminalRef) as (value: unknown) => void; + act(() => attach({ getShareTarget: () => null })); + expect(screen.getByTitle("share")).toBeInTheDocument(); + }); +});