feat/channelTabs (#483)

* holy addition gang

* performance optimization

* padding fix

* Update KeybindSettings.tsx

* Update tabs.tsx

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Jake 2025-11-09 18:35:36 +00:00 • committed by thororen1234
parent f48b7f3702
commit edb643fac2
No known key found for this signature in database
7 changed files with 810 additions and 64 deletions

View file

@ -99,6 +99,32 @@ export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => {
</div> : null;
};
interface TabNumberBadgeProps {
number: number;
position: "left" | "right";
isSelected: boolean;
isCompact: boolean;
isHovered: boolean;
}
export const TabNumberBadge = ({ number, position, isSelected, isCompact, isHovered }: TabNumberBadgeProps) => {
// hide badge if:
// 1. tab is currently selected
// 2. tab is compact AND not hovered
const shouldHide = isSelected || (isCompact && !isHovered);
if (shouldHide) return null;
return (
<div
className={cl("tab-number-badge", `position-${position}`)}
data-position={position}
>
{number}
</div>
);
};
function ChannelTabContent(props: ChannelTabsProps & {
guild?: Guild,
channel?: Channel;
@ -247,6 +273,9 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const [isClosing, setIsClosing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isDropTarget, setIsDropTarget] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { showTabNumbers, tabNumberPosition } = settings.use(["showTabNumbers", "tabNumberPosition"]);
useEffect(() => {
if (isEntering) {
@ -273,6 +302,7 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
const startX = e.clientX;
const startWidth = ref.current?.getBoundingClientRect().width || 0;
const baseWidth = 192; // 12rem in pixels (assuming 16px base font)
let pendingScale = settings.store.tabWidthScale;
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
@ -284,7 +314,12 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
// 50% and 200% scale
const clampedScale = Math.max(0.5, Math.min(2, newScale));
settings.store.tabWidthScale = Math.round(clampedScale * 100);
pendingScale = Math.round(clampedScale * 100);
// update CSS variable immediately for visual feedback
if (ref.current) {
ref.current.style.setProperty("--tab-width-scale", String(pendingScale / 100));
}
};
const handleMouseUp = () => {
@ -292,6 +327,12 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
settings.store.tabWidthScale = pendingScale;
if (ref.current) {
ref.current.style.removeProperty("--tab-width-scale");
}
};
document.addEventListener("mousemove", handleMouseMove);
@ -398,6 +439,8 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
})}
key={index}
ref={ref}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onAuxClick={e => {
if (e.button === 1 /* middle click */)
closeTab(id);
@ -412,7 +455,29 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
className={cl("tab-inner")}
data-compact={compact}
>
{/* left position badge */}
{showTabNumbers && tabNumberPosition === "left" && (
<TabNumberBadge
number={index + 1}
position="left"
isSelected={isTabSelected(id)}
isCompact={compact}
isHovered={isHovered}
/>
)}
<ChannelTabContent {...props} guild={guild} channel={channel} />
{/* right position badge */}
{showTabNumbers && tabNumberPosition === "right" && (
<TabNumberBadge
number={index + 1}
position="right"
isSelected={isTabSelected(id)}
isCompact={compact}
isHovered={isHovered}
/>
)}
</div>
</button>

View file

@ -13,7 +13,7 @@ import { useForceUpdater } from "@utils/react";
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, ContextMenuApi, Flex, FluxDispatcher, useCallback, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
import { BasicChannelTabsProps, ChannelTabsProps, clearStaleNavigationContext, createTab, handleChannelSwitch, isNavigationFromSource, moveToTab, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "../util";
import { BasicChannelTabsProps, ChannelTabsProps, clearStaleNavigationContext, closeTab, createTab, handleChannelSwitch, isNavigationFromSource, isTabSelected, moveToTab, openedTabs, openStartupTabs, saveTabs, settings, setUpdaterFunction, useGhostTabs } from "../util";
import BookmarkContainer from "./BookmarkContainer";
import ChannelTab, { PreviewTab } from "./ChannelTab";
import { BasicContextMenu } from "./ContextMenus";
@ -31,8 +31,15 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
showBookmarkBar,
widerTabsAndBookmarks,
tabWidthScale,
enableHotkeys,
hotkeyCount,
enableNumberKeySwitching,
numberKeySwitchCount,
enableCloseTabShortcut,
enableNewTabShortcut,
enableTabCycleShortcut,
closeTabKeybind,
newTabKeybind,
cycleTabForwardKeybind,
cycleTabBackwardKeybind,
tabBarPosition,
animationHover,
animationSelection,
@ -55,8 +62,15 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
"showBookmarkBar",
"widerTabsAndBookmarks",
"tabWidthScale",
"enableHotkeys",
"hotkeyCount",
"enableNumberKeySwitching",
"numberKeySwitchCount",
"enableCloseTabShortcut",
"enableNewTabShortcut",
"enableTabCycleShortcut",
"closeTabKeybind",
"newTabKeybind",
"cycleTabForwardKeybind",
"cycleTabBackwardKeybind",
"tabBarPosition",
"animationHover",
"animationSelection",
@ -86,6 +100,7 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
}, [userId]);
const ref = useRef<HTMLDivElement>(null);
const tabScrollerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setUpdaterFunction(update);
@ -115,11 +130,35 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
_update();
}, [widerTabsAndBookmarks]);
useEffect(() => {
if (!enableHotkeys) return;
const matchesKeybind = (event: KeyboardEvent, keybindString: string): boolean => {
const parts = keybindString.split("+");
const hasCtrl = parts.includes("CTRL");
const hasShift = parts.includes("SHIFT");
const hasAlt = parts.includes("ALT");
const mainKey = parts[parts.length - 1].toLowerCase();
const ctrlPressed = event.ctrlKey || event.metaKey;
const shiftPressed = event.shiftKey;
const altPressed = event.altKey;
const keyPressed = event.key.toLowerCase();
// special handling for TAB key
if (mainKey === "tab") {
return hasCtrl === ctrlPressed && hasShift === shiftPressed && hasAlt === altPressed && keyPressed === "tab";
}
// special handling for SPACE
if (mainKey === "space") {
return hasCtrl === ctrlPressed && hasShift === shiftPressed && hasAlt === altPressed && keyPressed === " ";
}
return hasCtrl === ctrlPressed && hasShift === shiftPressed && hasAlt === altPressed && keyPressed === mainKey;
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
// skip if typing in input fields
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
@ -128,23 +167,80 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
return;
}
const keyNumber = parseInt(event.key, 10);
if (!isNaN(keyNumber) && keyNumber >= 1 && keyNumber <= hotkeyCount) {
const tabIndex = keyNumber - 1;
if (openedTabs[tabIndex]) {
event.preventDefault();
moveToTab(openedTabs[tabIndex].id);
// 1. number key switching (1-9)
if (enableNumberKeySwitching) {
const keyNumber = parseInt(event.key, 10);
if (!isNaN(keyNumber) && keyNumber >= 1 && keyNumber <= numberKeySwitchCount) {
const tabIndex = keyNumber - 1;
if (openedTabs[tabIndex]) {
event.preventDefault();
moveToTab(openedTabs[tabIndex].id);
return;
}
}
}
// 2. close tab shortcut (default: CTRL+W)
if (enableCloseTabShortcut && matchesKeybind(event, closeTabKeybind)) {
event.preventDefault();
const currentTab = openedTabs.find(t => isTabSelected(t.id));
if (currentTab && openedTabs.length > 1) {
closeTab(currentTab.id);
}
return;
}
// 3. new tab shortcut (default: CTRL+T)
if (enableNewTabShortcut && matchesKeybind(event, newTabKeybind)) {
event.preventDefault();
event.stopPropagation(); // prevent discord's quick switcher from seeing this
createTab(props, true);
return;
}
// 4. cycle tabs forward (default: CTRL+TAB)
if (enableTabCycleShortcut && matchesKeybind(event, cycleTabForwardKeybind)) {
event.preventDefault();
event.stopPropagation(); // prevent discord's guild switcher from seeing this
const currentIndex = openedTabs.findIndex(t => isTabSelected(t.id));
if (currentIndex !== -1 && openedTabs.length > 1) {
const nextIndex = (currentIndex + 1) % openedTabs.length;
moveToTab(openedTabs[nextIndex].id);
}
return;
}
// 5. cycle tabs backward (default: CTRL+SHIFT+TAB)
if (enableTabCycleShortcut && matchesKeybind(event, cycleTabBackwardKeybind)) {
event.preventDefault();
event.stopPropagation(); // prevent Discord's guild switcher from seeing this
const currentIndex = openedTabs.findIndex(t => isTabSelected(t.id));
if (currentIndex !== -1 && openedTabs.length > 1) {
const nextIndex = (currentIndex - 1 + openedTabs.length) % openedTabs.length;
moveToTab(openedTabs[nextIndex].id);
}
return;
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [enableHotkeys, hotkeyCount]);
}, [
enableNumberKeySwitching,
numberKeySwitchCount,
enableCloseTabShortcut,
closeTabKeybind,
enableNewTabShortcut,
newTabKeybind,
enableTabCycleShortcut,
cycleTabForwardKeybind,
cycleTabBackwardKeybind,
props,
openedTabs
]);
useEffect(() => {
if (userId) {
@ -200,9 +296,11 @@ export default function ChannelsTabsContainer(props: BasicChannelTabsProps) {
<div className={cl("separator")} />
</>}
<div className={cl("tab-container")}>
{openedTabs.filter(tab => tab != null).map((tab, i) =>
<ChannelTab {...tab} index={i} key={tab.id} />
)}
<div className={cl("tab-scroller")} ref={tabScrollerRef}>
{openedTabs.filter(tab => tab != null).map((tab, i) =>
<ChannelTab {...tab} index={i} key={tab.id} />
)}
</div>
<button
onClick={() => createTab(props, true)}

View file

@ -0,0 +1,229 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Divider } from "@components/Divider";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import { Paragraph } from "@components/Paragraph";
import { IS_MAC } from "@utils/constants";
import { Button, React, Text, useEffect, useRef, useState } from "@webpack/common";
import { JSX } from "react";
import { settings } from "../util";
interface KeybindInputProps {
label: string;
description: string;
settingKey: "closeTabKeybind" | "newTabKeybind" | "cycleTabForwardKeybind" | "cycleTabBackwardKeybind";
enabledKey?: "enableCloseTabShortcut" | "enableNewTabShortcut" | "enableTabCycleShortcut";
}
function KeybindInput({ label, description, settingKey, enabledKey }: KeybindInputProps) {
const settingsValues = settings.use([settingKey]);
const currentKeybind = settingsValues[settingKey];
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isListening) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
// Ignore if only modifier keys are pressed
if (["Control", "Shift", "Alt", "Meta"].includes(event.key)) {
return;
}
// Build keybind string
const keys: string[] = [];
if (IS_MAC && event.ctrlKey) {
if (event.ctrlKey) keys.push("CONTROL");
if (event.metaKey) keys.push("CTRL");
} else if (event.ctrlKey) {
keys.push("CTRL");
}
if (event.shiftKey) keys.push("SHIFT");
if (event.altKey) keys.push("ALT");
// Normalize the key name
let mainKey = event.key.toUpperCase();
if (mainKey === " ") mainKey = "SPACE";
if (mainKey === "ESCAPE") mainKey = "ESC";
keys.push(mainKey);
const keybindString = keys.join("+");
// Check for conflicts with other keybinds
const allKeybinds = {
closeTabKeybind: settings.store.closeTabKeybind,
newTabKeybind: settings.store.newTabKeybind,
cycleTabForwardKeybind: settings.store.cycleTabForwardKeybind,
cycleTabBackwardKeybind: settings.store.cycleTabBackwardKeybind
};
// Check if this keybind is already used by another setting
const conflictKey = Object.entries(allKeybinds).find(
([key, value]) => key !== settingKey && value === keybindString
);
if (conflictKey) {
setError(`This keybind is already used by: ${conflictKey[0]}`);
setTimeout(() => setError(null), 3000);
setIsListening(false);
return;
}
settings.store[settingKey] = keybindString;
setError(null);
setIsListening(false);
};
const handleBlur = () => {
setIsListening(false);
};
document.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("blur", handleBlur);
buttonRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("blur", handleBlur);
};
}, [isListening, settingKey]);
const handleReset = () => {
const defaults = {
closeTabKeybind: "CTRL+W",
newTabKeybind: "CTRL+T",
cycleTabForwardKeybind: "CTRL+TAB",
cycleTabBackwardKeybind: "CTRL+SHIFT+TAB"
};
settings.store[settingKey] = defaults[settingKey];
setError(null);
};
const isEnabled = enabledKey ? settings.use([enabledKey])[enabledKey] : true;
return (
<div className="channelTabs-keybind-input">
<div className="channelTabs-keybind-info">
<Text variant="text-md/semibold">{label}</Text>
<Text variant="text-sm/normal" style={{ color: "var(--text-muted)" }}>
{description}
</Text>
{!isEnabled && (
<Text variant="text-xs/normal" style={{ color: "var(--text-danger)" }}>
This shortcut is currently disabled
</Text>
)}
{error && (
<Text variant="text-xs/normal" className="channelTabs-keybind-conflict">
{error}
</Text>
)}
</div>
<div className="channelTabs-keybind-controls">
<button
ref={buttonRef}
className={`channelTabs-keybind-button ${isListening ? "listening" : ""}`}
onClick={() => setIsListening(true)}
disabled={!isEnabled}
>
{isListening ? "Press any key..." : formatKeybind(currentKeybind)}
</button>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
onClick={handleReset}
disabled={!isEnabled}
>
Reset
</Button>
</div>
</div>
);
}
function formatKeybind(keybind: string): string {
const isMac = navigator.platform.toUpperCase().includes("MAC");
if (!isMac) {
return keybind;
}
// special case: CTRL+TAB must stay as CTRL on Mac (⌘+TAB is system app switcher)
if (keybind.includes("CTRL+TAB") || keybind.includes("CTRL+SHIFT+TAB")) {
return keybind.replace("CTRL", "^"); // Show control symbol on Mac
}
// for other shortcuts, replace CTRL with CMD symbol on Mac
return keybind.replace("CTRL", "⌘");
// this is such a bad way to do this but i dont know man
}
export function KeybindSettings(): JSX.Element {
const handleResetAll = () => {
settings.store.closeTabKeybind = "CTRL+W";
settings.store.newTabKeybind = "CTRL+T";
settings.store.cycleTabForwardKeybind = "CTRL+TAB";
settings.store.cycleTabBackwardKeybind = "CTRL+SHIFT+TAB";
};
return (
<ErrorBoundary>
<Divider />
<div className="channelTabs-keybind-settings">
<Heading>Keyboard Shortcuts</Heading>
<Paragraph>
Click a button and press your desired key combination. Modifiers like CTRL, SHIFT, and ALT are supported.
</Paragraph>
<KeybindInput
label="Close Tab"
description="Close the currently active tab"
settingKey="closeTabKeybind"
enabledKey="enableCloseTabShortcut"
/>
<KeybindInput
label="New Tab"
description="Open a new tab with the current channel"
settingKey="newTabKeybind"
enabledKey="enableNewTabShortcut"
/>
<KeybindInput
label="Cycle Tabs Forward"
description="Switch to the next tab (wraps around to first)"
settingKey="cycleTabForwardKeybind"
enabledKey="enableTabCycleShortcut"
/>
<KeybindInput
label="Cycle Tabs Backward"
description="Switch to the previous tab (wraps around to last)"
settingKey="cycleTabBackwardKeybind"
enabledKey="enableTabCycleShortcut"
/>
<div style={{ marginTop: "16px" }}>
<Button
color={Button.Colors.RED}
onClick={handleResetAll}
>
Reset All to Defaults
</Button>
</div>
</div>
</ErrorBoundary>
);
}

View file

@ -20,8 +20,8 @@ import * as ChannelTabsUtils from "./util";
const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channel: Channel, messageId?: string; }) => {
const { channel, messageId } = props;
const group = findGroupChildrenByChildId("channel-copy-link", children);
group?.push(
const menuItem = (
<Menu.MenuItem
label="Open in New Tab"
id="open-link-in-tab"
@ -31,6 +31,17 @@ const contextMenuPatch: NavContextMenuPatchCallback = (children, props: { channe
}, settings.store.openInNewTabAutoSwitch, messageId, true, true)} // The true values are important for bypassing tab limits
/>
);
const group = findGroupChildrenByChildId("channel-copy-link", children);
if (group) {
group.push(menuItem);
} else {
children.splice(-1, 0, (
<Menu.MenuGroup>
{menuItem}
</Menu.MenuGroup>
));
}
};
export default definePlugin({
@ -40,7 +51,9 @@ export default definePlugin({
dependencies: ["ContextMenuAPI"],
contextMenus: {
"channel-mention-context": contextMenuPatch,
"channel-context": contextMenuPatch
"channel-context": contextMenuPatch,
"user-context": contextMenuPatch,
"gdm-context": contextMenuPatch
},
patches: [
// add the channel tab container at the top
@ -95,12 +108,30 @@ export default definePlugin({
settings,
start() {
// migrate old settings to new granular keybind settings
const store = settings.store as any;
if (store.enableHotkeys !== undefined) {
const oldValue = store.enableHotkeys;
settings.store.enableNumberKeySwitching = oldValue;
settings.store.enableCloseTabShortcut = oldValue;
settings.store.enableNewTabShortcut = oldValue;
settings.store.enableTabCycleShortcut = oldValue;
delete store.enableHotkeys;
}
if (store.hotkeyCount !== undefined) {
settings.store.numberKeySwitchCount = store.hotkeyCount;
delete store.hotkeyCount;
}
},
containerHeight: 0,
flux: {
CHANNEL_SELECT(data: { channelId: string | null, guildId: string | null; }) {
// Skip if this navigation was triggered by us (clicking a tab)
if (ChannelTabsUtils.isNavigatingViaTab()) {
ChannelTabsUtils.clearNavigationFlag();
return;
}

View file

@ -44,7 +44,7 @@
.vc-channeltabs-tab-container,
.vc-channeltabs-bookmark-container {
display: flex;
flex-direction: row;
flex-flow: row nowrap;
align-items: center;
padding: 0 4px;
}
@ -95,6 +95,22 @@
display: none;
}
/* tab scroller for horizontal overflow */
.vc-channeltabs-tab-scroller {
display: flex;
flex-direction: row;
align-items: center;
overflow-x: auto;
white-space: nowrap;
flex: 1;
min-width: 0;
padding-top: 1px;
}
.vc-channeltabs-tab-scroller::-webkit-scrollbar {
display: none;
}
.vc-channeltabs-bookmarks {
flex-grow: 1;
overflow: hidden;
@ -183,6 +199,7 @@
padding: 6px 8px;
position: relative;
cursor: grab;
flex-shrink: 0;
}
.vc-channeltabs-tab:active {
@ -341,6 +358,7 @@
border-radius: 4px;
background-color: var(--background-secondary);
transition: all 0.2s ease;
flex-shrink: 0;
}
.vc-channeltabs-new-button:hover {
@ -587,7 +605,7 @@
}
}
/* Quests active gradient (blue+pink) when quests are running */
/* quests active gradient when quests are running */
.vc-channeltabs-tab-quests-active {
position: relative;
overflow: visible;
@ -601,9 +619,9 @@
background: linear-gradient(135deg,
transparent,
transparent 25%,
rgb(0 200 255) 40%,
rgb(114 137 218) 40%,
rgb(88 101 242) 50%,
rgb(220 104 255) 60%,
rgb(71 82 196) 60%,
transparent 75%,
transparent);
background-size: 300% 300%;
@ -644,7 +662,7 @@
@keyframes vc-channeltabs-quests-glow {
0% {
box-shadow: 0 0 8px 1px rgb(0 200 255 / 60%);
box-shadow: 0 0 8px 1px rgb(114 137 218 / 60%);
}
33% {
@ -652,11 +670,11 @@
}
66% {
box-shadow: 0 0 8px 1px rgb(220 104 255 / 60%);
box-shadow: 0 0 8px 1px rgb(71 82 196 / 60%);
}
100% {
box-shadow: 0 0 8px 1px rgb(0 200 255 / 60%);
box-shadow: 0 0 8px 1px rgb(114 137 218 / 60%);
}
}
@ -672,7 +690,7 @@
@keyframes vc-channeltabs-quests-glow-selected {
0% {
box-shadow: 0 0 12px 2px rgb(0 200 255 / 80%);
box-shadow: 0 0 12px 2px rgb(114 137 218 / 80%);
}
33% {
@ -680,11 +698,11 @@
}
66% {
box-shadow: 0 0 12px 2px rgb(220 104 255 / 80%);
box-shadow: 0 0 12px 2px rgb(71 82 196 / 80%);
}
100% {
box-shadow: 0 0 12px 2px rgb(0 200 255 / 80%);
box-shadow: 0 0 12px 2px rgb(114 137 218 / 80%);
}
}
@ -1052,3 +1070,184 @@
.vc-channeltabs-bookmark.vc-channeltabs-bookmark-active:hover {
box-shadow: 0 0 6px var(--brand-500);
}
/* keybind Settings Styles */
.channelTabs-keybind-settings {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
}
.channelTabs-keybind-input {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
background-color: var(--background-secondary);
border-radius: 8px;
transition: background-color 0.2s ease;
}
.channelTabs-keybind-input:hover {
background-color: var(--background-secondary-alt);
}
.channelTabs-keybind-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.channelTabs-keybind-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.channelTabs-keybind-button {
padding: 8px 16px;
background-color: var(--background-tertiary);
border: 2px solid var(--input-border);
border-radius: 8px;
cursor: pointer;
min-width: 140px;
text-align: center;
font-family: var(--font-code);
font-weight: 600;
color: var(--header-primary);
transition: all 0.2s ease;
outline: none;
box-shadow: 0 0 0 1px var(--background-modifier-accent);
}
.channelTabs-keybind-button:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
border-color: var(--brand-experiment);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgb(0 0 0 / 20%);
}
.channelTabs-keybind-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 4px rgb(0 0 0 / 20%);
}
.channelTabs-keybind-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.channelTabs-keybind-button.listening {
background: linear-gradient(135deg, var(--brand-experiment) 0%, var(--brand-experiment-560) 100%);
color: var(--white);
border-color: var(--brand-experiment);
animation: keybind-pulse 1.5s ease-in-out infinite;
box-shadow: 0 0 12px var(--brand-experiment);
}
@keyframes keybind-pulse {
/* stylelint-disable-next-line rule-empty-line-before */
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.02);
}
}
.channelTabs-keybind-conflict {
color: var(--text-danger);
font-size: 12px;
font-weight: 500;
margin-top: 2px;
animation: shake 0.3s ease;
}
@keyframes shake {
/* stylelint-disable-next-line rule-empty-line-before */
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
/* Tab Number Badge Styles */
.vc-channeltabs-tab-number-badge {
background-color: rgb(0 0 0 / 25%);
color: var(--text-normal);
border-radius: 50%;
width: 20px;
height: 20px;
min-width: 20px;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
opacity 0.4s ease-out 0.2s,
transform 0.4s ease-out 0.2s,
margin 0.4s ease-out;
flex-shrink: 0;
}
/* position: Left */
.vc-channeltabs-tab-number-badge[data-position="left"] {
margin-right: 0.5rem;
transition:
opacity 0.4s ease-out,
transform 0.4s ease-out,
margin-right 0.4s ease-out;
}
/* position: Right */
.vc-channeltabs-tab-number-badge[data-position="right"] {
margin-left: 0.5rem;
transition:
opacity 0.4s ease-out 0.2s,
transform 0.4s ease-out 0.2s,
margin-left 0.4s ease-out;
}
/* hover effect (slight scale up) */
.vc-channeltabs-tab:hover .vc-channeltabs-tab-number-badge {
transform: scale(1.05);
}
/* directional slide animations for compact mode */
.vc-channeltabs-tab-compact:not(:hover, .vc-channeltabs-tab-selected) .vc-channeltabs-tab-number-badge[data-position="right"] {
opacity: 0;
transform: translateX(-10px) scale(0.95);
pointer-events: none;
}
.vc-channeltabs-tab-compact:not(:hover, .vc-channeltabs-tab-selected) .vc-channeltabs-tab-number-badge[data-position="left"] {
opacity: 0;
transform: translateX(-15px) scale(0.9);
pointer-events: none;
}
/* disable transitions when compact expand animation is disabled */
.vc-channeltabs-no-compact-animation .vc-channeltabs-tab-number-badge {
transition: none !important;
}

View file

@ -13,6 +13,7 @@ import { SearchableSelect, useState } from "@webpack/common";
import { JSX } from "react";
import { ChannelTabsPreview } from "../components/ChannelTabsContainer";
import { KeybindSettings } from "../components/KeybindSettings";
interface DynamicDropdownSettingOption {
label: string;
@ -137,7 +138,6 @@ export const settings = definePluginSettings({
},
tabSet: {
component: ChannelTabsPreview,
description: "Select which tabs to open at startup",
type: OptionType.COMPONENT,
default: {}
},
@ -214,19 +214,81 @@ export const settings = definePluginSettings({
],
restartNeeded: true
},
enableHotkeys: {
enableNumberKeySwitching: {
type: OptionType.BOOLEAN,
description: "Enable hotkey (1-9) for tab switching",
description: "Enable number keys (1-9) to switch tabs",
default: true,
restartNeeded: false
},
hotkeyCount: {
numberKeySwitchCount: {
type: OptionType.SLIDER,
description: "Number of tabs accessible via keyboard shortcuts",
description: "Number of tabs accessible via number keys (1-9)",
markers: makeRange(1, 9, 1),
default: 3,
stickToMarkers: true,
},
enableCloseTabShortcut: {
type: OptionType.BOOLEAN,
description: "Enable close tab keyboard shortcut",
default: true,
restartNeeded: false
},
enableNewTabShortcut: {
type: OptionType.BOOLEAN,
description: "Enable new tab keyboard shortcut",
default: true,
restartNeeded: false
},
enableTabCycleShortcut: {
type: OptionType.BOOLEAN,
description: "Enable tab cycling keyboard shortcut",
default: true,
restartNeeded: false
},
keybindsSection: {
type: OptionType.COMPONENT,
component: KeybindSettings
},
// me when storage yes for keybinds
closeTabKeybind: {
type: OptionType.STRING,
description: "Keyboard shortcut to close the current tab",
default: "CTRL+W",
hidden: true
},
newTabKeybind: {
type: OptionType.STRING,
description: "Keyboard shortcut to open a new tab",
default: "CTRL+T",
hidden: true
},
cycleTabForwardKeybind: {
type: OptionType.STRING,
description: "Keyboard shortcut to cycle to the next tab",
default: "CTRL+TAB",
hidden: true
},
cycleTabBackwardKeybind: {
type: OptionType.STRING,
description: "Keyboard shortcut to cycle to the previous tab",
default: "CTRL+SHIFT+TAB",
hidden: true
},
showTabNumbers: {
type: OptionType.BOOLEAN,
description: "Show numbered badges on tabs to indicate keyboard shortcuts",
default: false,
restartNeeded: false
},
tabNumberPosition: {
type: OptionType.SELECT,
description: "Where to display the numbered badge on tabs",
options: [
{ label: "Left side (before icon)", value: "left", default: true },
{ label: "Right side (after content)", value: "right" }
],
restartNeeded: false
},
animations: {
type: OptionType.COMPONENT,
component: AnimationSettings

View file

@ -6,7 +6,7 @@
import { DataStore } from "@api/index";
import { classNameFactory } from "@api/Styles";
import { NavigationRouter, SelectedChannelStore, SelectedGuildStore, showToast, Toasts, useState } from "@webpack/common";
import { NavigationRouter, SelectedChannelStore, SelectedGuildStore, showToast, Toasts, useEffect, useRef, useState } from "@webpack/common";
import { JSX } from "react";
import { logger, settings } from "./constants";
@ -25,6 +25,8 @@ interface NavigationContext {
let lastNavigationContext: NavigationContext | null = null;
let isViewingViaBookmark = false;
let isNavigatingViaTabFlag = false;
let navigationTimeoutId: NodeJS.Timeout | undefined;
const NAVIGATION_TIMEOUT_MS = 1000;
export function isViewingViaBookmarkMode() {
return settings.store.bookmarksIndependentFromTabs && isViewingViaBookmark;
@ -34,6 +36,11 @@ export function isNavigatingViaTab() {
return isNavigatingViaTabFlag;
}
export function clearNavigationFlag() {
clearTimeout(navigationTimeoutId);
isNavigatingViaTabFlag = false;
}
export function clearBookmarkViewingMode() {
isViewingViaBookmark = false;
}
@ -94,6 +101,8 @@ interface TabStateCache {
timestamp: number;
}
const tabStateCache = new Map<number, TabStateCache>();
const MAX_CACHE_SIZE = 50;
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
// horror
const _ = {
@ -170,22 +179,31 @@ export function closeTab(id: number) {
if (id === currentlyOpenTab) {
if (openTabHistory.length) {
// use tab history to find most recently used tab
openTabHistory.pop();
let newTab: ChannelTabsProps | undefined = undefined;
while (!newTab) {
const maybeNewTabId = openTabHistory.at(-1);
openTabHistory.pop();
if (!maybeNewTabId) {
moveToTab(openTabs[Math.max(i - 1, 0)].id);
// fallback: go to tab on the right, or leftmost if closing last tab
const fallbackIndex = i < openTabs.length ? i : 0;
moveToTab(openTabs[fallbackIndex].id);
break;
}
const maybeNewTab = openTabs.find(t => t.id === maybeNewTabId);
if (maybeNewTab) newTab = maybeNewTab;
}
moveToTab(newTab.id);
openTabHistory.pop();
if (newTab) {
moveToTab(newTab.id);
openTabHistory.pop();
}
} else {
// no history: go to tab on the right, or leftmost if closing last tab
const fallbackIndex = i < openTabs.length ? i : 0;
moveToTab(openTabs[fallbackIndex].id);
}
else moveToTab(openTabs[Math.max(i - 1, 0)].id);
}
if (i !== openTabs.length) bumpGhostTabCount();
else clearGhostTabs();
@ -319,11 +337,33 @@ function getScrollContainer(): HTMLElement | null {
return document.querySelector('[class*="scrollerInner_"]') as HTMLElement;
}
function evictStaleCache() {
const now = Date.now();
for (const [tabId, cache] of tabStateCache.entries()) {
if (now - cache.timestamp > CACHE_TTL_MS) {
tabStateCache.delete(tabId);
}
}
if (tabStateCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(tabStateCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const entriesToRemove = entries.slice(0, tabStateCache.size - MAX_CACHE_SIZE);
for (const [tabId] of entriesToRemove) {
tabStateCache.delete(tabId);
}
}
}
function cacheCurrentTabState() {
if (!settings.store.renderAllTabs) return;
const scrollContainer = getScrollContainer();
if (scrollContainer && currentlyOpenTab !== undefined) {
evictStaleCache();
tabStateCache.set(currentlyOpenTab, {
scrollPosition: scrollContainer.scrollTop,
timestamp: Date.now()
@ -361,6 +401,7 @@ export function moveToTab(id: number) {
setOpenTab(id);
// SET FLAG: We're initiating navigation
clearTimeout(navigationTimeoutId);
isNavigatingViaTabFlag = true;
// handle special pages with synthetic channelIds
@ -381,8 +422,10 @@ export function moveToTab(id: number) {
if (route) {
setNavigationSource(tab.guildId, tab.channelId, "tab");
NavigationRouter.transitionTo(route);
// Clear flag after navigation completes
setTimeout(() => { isNavigatingViaTabFlag = false; }, 100);
// Clear flag after navigation with safety timeout
navigationTimeoutId = setTimeout(() => {
isNavigatingViaTabFlag = false;
}, NAVIGATION_TIMEOUT_MS);
return;
}
}
@ -401,8 +444,10 @@ export function moveToTab(id: number) {
}
else update();
// Clear flag after navigation
setTimeout(() => { isNavigatingViaTabFlag = false; }, 100);
// Clear flag after navigation with safety timeout
navigationTimeoutId = setTimeout(() => {
isNavigatingViaTabFlag = false;
}, NAVIGATION_TIMEOUT_MS);
}
export function openStartupTabs(props: BasicChannelTabsProps & { userId: string; }, setUserId: (id: string) => void) {
@ -417,19 +462,26 @@ export function openStartupTabs(props: BasicChannelTabsProps & { userId: string;
switch (settings.store.onStartup) {
case "remember": {
persistedTabs.then(tabs => {
const t = tabs?.[userId];
if (!t) {
createTab({ channelId: props.channelId, guildId: props.guildId }, true);
return showToast("Failed to restore tabs", Toasts.Type.FAILURE);
}
replaceArray(openTabs); // empty the array
t.openTabs.forEach(tab => createTab(tab));
currentlyOpenTab = openTabs[t.openTabIndex]?.id ?? 0;
persistedTabs
.then(tabs => {
const t = tabs?.[userId];
if (!t) {
createTab({ channelId: props.channelId, guildId: props.guildId }, true);
return showToast("Failed to restore tabs", Toasts.Type.FAILURE);
}
replaceArray(openTabs); // empty the array
t.openTabs.forEach(tab => createTab(tab));
currentlyOpenTab = openTabs[t.openTabIndex]?.id ?? 0;
setUserId(userId);
moveToTab(currentlyOpenTab);
});
setUserId(userId);
moveToTab(currentlyOpenTab);
})
.catch(error => {
logger.error("Failed to load persisted tabs from DataStore", error);
showToast("Failed to load saved tabs", Toasts.Type.FAILURE);
createTab({ channelId: props.channelId, guildId: props.guildId }, true);
setUserId(userId);
});
break;
}
case "preset": {
@ -540,18 +592,28 @@ export function toggleCompactTab(id: number) {
}
export function useGhostTabs() {
let timeout;
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const [count, setCount] = useState(0);
bumpGhostTabCount = () => {
setCount(count + 1);
clearTimeout(timeout);
timeout = setTimeout(() => {
setCount(prev => prev + 1);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setCount(0);
}, 3000);
};
clearGhostTabs = () => {
clearTimeout(timeout);
clearTimeout(timeoutRef.current);
setCount(0);
};
// cleanup timeout on unmount
useEffect(() => {
return () => {
clearTimeout(timeoutRef.current);
};
}, []);
return new Array<JSX.Element>(count).fill(<div className={cl("tab", "ghost-tab")} />);
}