Add pinnable new-tab targets to the tab row and sidebar
Any new-tab target (new agent, terminal, browser, or a terminal profile) can be pinned as a quick-launch button. Pins are global client app state (persisted), not daemon config, so the same set shows on every workspace; profile pins resolve per host and are dropped where that profile is absent. The sidebar mirror launches via a navigate-then-launch intent store rather than calling the create handlers directly: terminal creation only works in the mounted/focused workspace, so the sidebar requests the launch and the workspace screen consumes it on focus.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useLaunchIntentStore } from "./launch-intent-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useLaunchIntentStore.setState({ pending: null });
|
||||
});
|
||||
|
||||
describe("launch intent store", () => {
|
||||
it("returns the requested target when the workspace key matches", () => {
|
||||
useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "terminal" } });
|
||||
expect(useLaunchIntentStore.getState().consume("ws-1")).toEqual({ kind: "terminal" });
|
||||
});
|
||||
|
||||
it("clears the pending intent once consumed", () => {
|
||||
useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "draft" } });
|
||||
useLaunchIntentStore.getState().consume("ws-1");
|
||||
expect(useLaunchIntentStore.getState().pending).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null and keeps the intent when the workspace key does not match", () => {
|
||||
useLaunchIntentStore.getState().request({ workspaceKey: "ws-1", target: { kind: "browser" } });
|
||||
expect(useLaunchIntentStore.getState().consume("ws-2")).toBeNull();
|
||||
expect(useLaunchIntentStore.getState().pending).toEqual({
|
||||
workspaceKey: "ws-1",
|
||||
target: { kind: "browser" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no pending intent", () => {
|
||||
expect(useLaunchIntentStore.getState().consume("ws-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import type { PinnedTabTarget } from "@/workspace-pins/target";
|
||||
|
||||
export interface PendingTabLaunch {
|
||||
workspaceKey: string;
|
||||
target: PinnedTabTarget;
|
||||
}
|
||||
|
||||
interface LaunchIntentState {
|
||||
pending: PendingTabLaunch | null;
|
||||
request: (intent: PendingTabLaunch) => void;
|
||||
consume: (workspaceKey: string) => PinnedTabTarget | null;
|
||||
}
|
||||
|
||||
export const useLaunchIntentStore = create<LaunchIntentState>((set, get) => ({
|
||||
pending: null,
|
||||
request: (intent) => set({ pending: intent }),
|
||||
consume: (workspaceKey) => {
|
||||
const { pending } = get();
|
||||
if (!pending || pending.workspaceKey !== workspaceKey) {
|
||||
return null;
|
||||
}
|
||||
set({ pending: null });
|
||||
return pending.target;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target";
|
||||
import { usePinnedTargetsStore } from "@/workspace-pins/store";
|
||||
|
||||
export interface ResolvedPin {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface UsePinnedLaunchersInput {
|
||||
serverId: string;
|
||||
onLaunch: (target: PinnedTabTarget) => void;
|
||||
}
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
|
||||
function ProviderPinIcon({
|
||||
iconKey,
|
||||
size,
|
||||
color = "",
|
||||
}: {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderPinIcon = withUnistyles(ProviderPinIcon);
|
||||
|
||||
export function ProfileIcon({ iconKey }: { iconKey: string | undefined }): ReactElement {
|
||||
if (!iconKey) {
|
||||
return <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
return <ThemedProviderPinIcon iconKey={iconKey} size={14} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
|
||||
export function usePinnedLaunchers({ serverId, onLaunch }: UsePinnedLaunchersInput): ResolvedPin[] {
|
||||
const { t } = useTranslation();
|
||||
const pinned = usePinnedTargetsStore((state) => state.pinned);
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const profiles = useMemo(
|
||||
() => resolveTerminalProfiles(config?.terminalProfiles),
|
||||
[config?.terminalProfiles],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const resolved: ResolvedPin[] = [];
|
||||
for (const target of pinned) {
|
||||
if (target.kind === "draft") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.newAgent"),
|
||||
icon: <ThemedSquarePen size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.newTerminal"),
|
||||
icon: <ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (target.kind === "browser") {
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: t("workspace.tabs.actions.newBrowser"),
|
||||
icon: <ThemedGlobe size={14} uniProps={mutedColorMapping} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const profile = profiles.find((entry) => entry.id === target.profileId);
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
resolved.push({
|
||||
key: pinnedTargetKey(target),
|
||||
label: profile.name,
|
||||
icon: <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||
onPress: () => onLaunch(target),
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}, [onLaunch, pinned, profiles, t]);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pin, PinOff } from "lucide-react-native";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { pinnedTargetKey, type PinnedTabTarget } from "@/workspace-pins/target";
|
||||
import { usePinnedTargetsStore } from "@/workspace-pins/store";
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
const ThemedPinOff = withUnistyles(PinOff);
|
||||
|
||||
interface PinnableMenuItemProps {
|
||||
target: PinnedTabTarget;
|
||||
label: string;
|
||||
leading: ReactElement;
|
||||
disabled?: boolean;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function PinnableMenuItem({
|
||||
target,
|
||||
label,
|
||||
leading,
|
||||
disabled,
|
||||
onSelect,
|
||||
testID,
|
||||
}: PinnableMenuItemProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isPinned = usePinnedTargetsStore((state) => state.isPinned(target));
|
||||
const toggle = usePinnedTargetsStore((state) => state.toggle);
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
toggle(target);
|
||||
},
|
||||
[target, toggle],
|
||||
);
|
||||
|
||||
const showToggle = isHovered || isNative || isCompact;
|
||||
|
||||
const slotStyle = useMemo(
|
||||
() => [styles.pinToggleSlot, showToggle ? styles.pinToggleShown : styles.pinToggleHidden],
|
||||
[showToggle],
|
||||
);
|
||||
|
||||
const trailing = useMemo(
|
||||
() => (
|
||||
<View style={slotStyle} pointerEvents={showToggle ? "auto" : "none"}>
|
||||
<Pressable
|
||||
onPress={handleTogglePin}
|
||||
hitSlop={8}
|
||||
style={styles.pinToggleButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPinned
|
||||
? t("workspace.tabs.actions.unpinTarget")
|
||||
: t("workspace.tabs.actions.pinTarget")
|
||||
}
|
||||
testID={`workspace-pin-toggle-${pinnedTargetKey(target)}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<ThemedPinOff size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<ThemedPin size={14} uniProps={mutedColorMapping} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
[handleTogglePin, isPinned, showToggle, slotStyle, t, target],
|
||||
);
|
||||
|
||||
return (
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<DropdownMenuItem
|
||||
testID={testID}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
pinToggleSlot: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pinToggleHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
pinToggleShown: {
|
||||
opacity: 1,
|
||||
},
|
||||
pinToggleButton: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { ResolvedPin } from "@/workspace-pins/launch";
|
||||
|
||||
function pinButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.pinButton, (Boolean(hovered) || pressed) && styles.pinButtonHovered];
|
||||
}
|
||||
|
||||
interface PinnedTargetsRowProps {
|
||||
launchers: ResolvedPin[];
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
export function PinnedTargetsRow({ launchers, testIdPrefix }: PinnedTargetsRowProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{launchers.map((launcher) => (
|
||||
<Tooltip key={launcher.key} delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID={`${testIdPrefix}-${launcher.key}`}
|
||||
onPress={launcher.onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={launcher.label}
|
||||
style={pinButtonStyle}
|
||||
>
|
||||
{launcher.icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{launcher.label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
pinButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pinButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import { runPinnedTabTarget, type TabTargetHandlers } from "./run";
|
||||
|
||||
const PROFILES: readonly TerminalProfile[] = [
|
||||
{ id: "claude", name: "Claude Code", command: "claude" },
|
||||
];
|
||||
|
||||
interface RecordedLaunch {
|
||||
action: "draft" | "terminal" | "browser" | "profile";
|
||||
profile?: TerminalProfileInput;
|
||||
}
|
||||
|
||||
function recordingHandlers() {
|
||||
const launches: RecordedLaunch[] = [];
|
||||
const handlers: TabTargetHandlers = {
|
||||
createDraft: () => launches.push({ action: "draft" }),
|
||||
createTerminal: () => launches.push({ action: "terminal" }),
|
||||
createBrowser: () => launches.push({ action: "browser" }),
|
||||
createTerminalWithProfile: (profile) => launches.push({ action: "profile", profile }),
|
||||
};
|
||||
return { launches, handlers };
|
||||
}
|
||||
|
||||
describe("runPinnedTabTarget", () => {
|
||||
it("creates a draft agent for the draft target", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "draft" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([{ action: "draft" }]);
|
||||
});
|
||||
|
||||
it("creates a terminal for the terminal target", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "terminal" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([{ action: "terminal" }]);
|
||||
});
|
||||
|
||||
it("creates a browser for the browser target", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "browser" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([{ action: "browser" }]);
|
||||
});
|
||||
|
||||
it("launches the resolved profile command for a known profile target", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "profile", profileId: "claude" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([
|
||||
{ action: "profile", profile: { name: "Claude Code", command: "claude" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does nothing when the profile id is absent from the host", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "profile", profileId: "missing" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import type { PinnedTabTarget } from "@/workspace-pins/target";
|
||||
|
||||
export interface TabTargetHandlers {
|
||||
createDraft: () => void;
|
||||
createTerminal: () => void;
|
||||
createBrowser: () => void;
|
||||
createTerminalWithProfile: (profile: TerminalProfileInput) => void;
|
||||
}
|
||||
|
||||
export function runPinnedTabTarget(
|
||||
target: PinnedTabTarget,
|
||||
profiles: readonly TerminalProfile[],
|
||||
handlers: TabTargetHandlers,
|
||||
): void {
|
||||
if (target.kind === "draft") {
|
||||
handlers.createDraft();
|
||||
return;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
handlers.createTerminal();
|
||||
return;
|
||||
}
|
||||
if (target.kind === "browser") {
|
||||
handlers.createBrowser();
|
||||
return;
|
||||
}
|
||||
const profile = profiles.find((entry) => entry.id === target.profileId);
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
handlers.createTerminalWithProfile({
|
||||
name: profile.name,
|
||||
command: profile.command,
|
||||
args: profile.args,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target";
|
||||
|
||||
interface PinnedTargetsState {
|
||||
pinned: PinnedTabTarget[];
|
||||
toggle: (target: PinnedTabTarget) => void;
|
||||
isPinned: (target: PinnedTabTarget) => boolean;
|
||||
}
|
||||
|
||||
export const usePinnedTargetsStore = create<PinnedTargetsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
pinned: [],
|
||||
toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })),
|
||||
isPinned: (target) => isTargetPinned(get().pinned, target),
|
||||
}),
|
||||
{
|
||||
name: "pinned-tab-targets",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({ pinned: state.pinned }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTargetPinned, pinnedTargetKey, togglePinnedTarget } from "./target";
|
||||
|
||||
describe("pinnedTargetKey", () => {
|
||||
it("uses the bare kind as the key for non-profile targets", () => {
|
||||
expect(pinnedTargetKey({ kind: "draft" })).toBe("draft");
|
||||
expect(pinnedTargetKey({ kind: "terminal" })).toBe("terminal");
|
||||
expect(pinnedTargetKey({ kind: "browser" })).toBe("browser");
|
||||
});
|
||||
|
||||
it("namespaces a profile target by its profile id", () => {
|
||||
expect(pinnedTargetKey({ kind: "profile", profileId: "claude" })).toBe("profile:claude");
|
||||
});
|
||||
|
||||
it("gives two profiles with different ids different keys", () => {
|
||||
const claude = pinnedTargetKey({ kind: "profile", profileId: "claude" });
|
||||
const codex = pinnedTargetKey({ kind: "profile", profileId: "codex" });
|
||||
expect(claude).not.toBe(codex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePinnedTarget / isTargetPinned", () => {
|
||||
it("pins a target that is not yet pinned", () => {
|
||||
const pinned = togglePinnedTarget([], { kind: "terminal" });
|
||||
expect(isTargetPinned(pinned, { kind: "terminal" })).toBe(true);
|
||||
});
|
||||
|
||||
it("unpins a target that is already pinned", () => {
|
||||
const pinned = togglePinnedTarget([{ kind: "browser" }], { kind: "browser" });
|
||||
expect(isTargetPinned(pinned, { kind: "browser" })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats profiles with different ids as independent pins", () => {
|
||||
const pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" });
|
||||
expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(true);
|
||||
expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(false);
|
||||
});
|
||||
|
||||
it("unpins only the matching profile id", () => {
|
||||
let pinned = togglePinnedTarget([], { kind: "profile", profileId: "claude" });
|
||||
pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "codex" });
|
||||
pinned = togglePinnedTarget(pinned, { kind: "profile", profileId: "claude" });
|
||||
|
||||
expect(isTargetPinned(pinned, { kind: "profile", profileId: "claude" })).toBe(false);
|
||||
expect(isTargetPinned(pinned, { kind: "profile", profileId: "codex" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export type PinnedTabTarget =
|
||||
| { kind: "draft" }
|
||||
| { kind: "terminal" }
|
||||
| { kind: "browser" }
|
||||
| { kind: "profile"; profileId: string };
|
||||
|
||||
export function pinnedTargetKey(target: PinnedTabTarget): string {
|
||||
if (target.kind === "profile") {
|
||||
return `profile:${target.profileId}`;
|
||||
}
|
||||
return target.kind;
|
||||
}
|
||||
|
||||
export function isTargetPinned(
|
||||
pinned: readonly PinnedTabTarget[],
|
||||
target: PinnedTabTarget,
|
||||
): boolean {
|
||||
const key = pinnedTargetKey(target);
|
||||
return pinned.some((entry) => pinnedTargetKey(entry) === key);
|
||||
}
|
||||
|
||||
export function togglePinnedTarget(
|
||||
pinned: readonly PinnedTabTarget[],
|
||||
target: PinnedTabTarget,
|
||||
): PinnedTabTarget[] {
|
||||
const key = pinnedTargetKey(target);
|
||||
const next = pinned.filter((entry) => pinnedTargetKey(entry) !== key);
|
||||
if (next.length === pinned.length) {
|
||||
next.push(target);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user