fix: tab buttons like share show as soon as the terminal is ready

This commit is contained in:
LukeGus committed 2026-10-08 14:04:27 -05:00
1 parent 5e7f69456b
commit ab2307bab4
5 files changed
+162 -2

No files matched your search

+5 -1
View File
@@ -52,6 +52,10 @@ function contextKey(context: Record<string, unknown> | 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(",");
}
}
+3
View File
@@ -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<string | null>(null);
+45
View File
@@ -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<RefObject<unknown>, (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<unknown> | undefined,
): Ref<unknown> | 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,
);
}
+4 -1
View File
@@ -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<unknown>}
handleRef={trackTabHandle(
tab.terminalRef as React.RefObject<unknown> | undefined,
)}
shell={shell}
/>
);
+105
View File
@@ -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<unknown>();
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<unknown>();
const tabs = [
{ id: "terminal-1", type: "terminal", label: "web-01", terminalRef },
] as unknown as Tab[];
render(
<TabBar
tabs={tabs}
activeTabId="terminal-1"
splits={[]}
activeSplitFull={false}
onSetActiveTab={() => {}}
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();
});
});