mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: tab buttons like share show as soon as the terminal is ready
This commit is contained in:
1 parent
5e7f69456b
commit
ab2307bab4
5 files changed
+162
-2
No files matched your search
@@ -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(",");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user