diff --git a/src/equicordplugins/channelTabs/components/ChannelTab.tsx b/src/equicordplugins/channelTabs/components/ChannelTab.tsx index 9df3ae3b1..3a040761d 100644 --- a/src/equicordplugins/channelTabs/components/ChannelTab.tsx +++ b/src/equicordplugins/channelTabs/components/ChannelTab.tsx @@ -99,6 +99,32 @@ export const NotificationDot = ({ channelIds }: { channelIds: string[]; }) => { : 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 ( +
+ {number} +
+ ); +}; + 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" && ( + + )} + + + {/* right position badge */} + {showTabNumbers && tabNumberPosition === "right" && ( + + )} diff --git a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx index f0d7e6502..e7df8227a 100644 --- a/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx +++ b/src/equicordplugins/channelTabs/components/ChannelTabsContainer.tsx @@ -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(null); + const tabScrollerRef = useRef(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) {
}
- {openedTabs.filter(tab => tab != null).map((tab, i) => - - )} +
+ {openedTabs.filter(tab => tab != null).map((tab, i) => + + )} +
+ +
+
+ ); +} + +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 ( + + +
+ Keyboard Shortcuts + + Click a button and press your desired key combination. Modifiers like CTRL, SHIFT, and ALT are supported. + + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/equicordplugins/channelTabs/index.tsx b/src/equicordplugins/channelTabs/index.tsx index 7b78dcf87..fae77a515 100644 --- a/src/equicordplugins/channelTabs/index.tsx +++ b/src/equicordplugins/channelTabs/index.tsx @@ -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 = ( ); + + const group = findGroupChildrenByChildId("channel-copy-link", children); + if (group) { + group.push(menuItem); + } else { + children.splice(-1, 0, ( + + {menuItem} + + )); + } }; 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; } diff --git a/src/equicordplugins/channelTabs/style.css b/src/equicordplugins/channelTabs/style.css index a353284f4..f8eb43145 100644 --- a/src/equicordplugins/channelTabs/style.css +++ b/src/equicordplugins/channelTabs/style.css @@ -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; +} diff --git a/src/equicordplugins/channelTabs/util/constants.tsx b/src/equicordplugins/channelTabs/util/constants.tsx index 5573a7103..90528df33 100644 --- a/src/equicordplugins/channelTabs/util/constants.tsx +++ b/src/equicordplugins/channelTabs/util/constants.tsx @@ -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 diff --git a/src/equicordplugins/channelTabs/util/tabs.tsx b/src/equicordplugins/channelTabs/util/tabs.tsx index b2d16c17a..b31d1f593 100644 --- a/src/equicordplugins/channelTabs/util/tabs.tsx +++ b/src/equicordplugins/channelTabs/util/tabs.tsx @@ -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(); +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(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(count).fill(
); }