Slidebar - GitHub PR Sidebar Enhancer

Make GitHub's PR sidebar actually usable - resizable, with tooltips and optional horizontal scrolling. Settings are persistent and configurable via a button on the toolbar.

目前為 2025-08-17 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Slidebar - GitHub PR Sidebar Enhancer
// @namespace    https://github.com/AstroMash/userscripts
// @version      1.0.0
// @description  Make GitHub's PR sidebar actually usable - resizable, with tooltips and optional horizontal scrolling. Settings are persistent and configurable via a button on the toolbar.
// @author       AstroMash
// @icon         https://raw.githubusercontent.com/astromash/userscripts/main/scripts/github-slidebar/icon.png
// @match        https://github.com/*/pull/*
// @match        https://github.com/*/pulls/*
// @match        https://github.com/*/compare/*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const APP_NAME = 'Slidebar';
    const STORAGE_KEY = 'slidebarConfig';
    const MAX_INIT_ATTEMPTS = 10;

    const DEFAULT_CONFIG = {
        enableResizing: true,
        enableTooltips: true,
        enableHorizontalScroll: false,
        sidebarWidth: 296, // GitHub's default
        minWidth: 200,
        maxWidth: 600,
    };

    let config = { ...DEFAULT_CONFIG, ...GM_getValue(STORAGE_KEY, {}) };
    let initAttempts = 0;
    let configButtonAttempts = 0;
    let configButtonAdded = false;
    let currentObserver = null;
    let resizeCleanup = null;
    let isInitialized = false;

    if (typeof GM_addStyle === 'function') {
        GM_addStyle(`
            /* Resize handle */
            /*****************/

            .slidebar-resize-handle {
                position: absolute;
                width: 4px;
                height: 100%;
                cursor: col-resize;
                z-index: 100;
                background: transparent;
                transition: background 0.2s ease;
            }
            .slidebar-resize-handle:hover,
            .slidebar-resize-handle.dragging {
                background: rgba(59, 130, 246, 0.5);
            }

            /* Config button */
            /*****************/

            .slidebar-config-btn {
                background: none;
                border: none;
                padding: 4px;
                cursor: pointer;
                color: var(--fgColor-muted);
                transition: color 0.2s ease;
            }
            .slidebar-config-btn:hover {
                color: var(--fgColor-accent);
            }

            /* Modal */
            /*********/

            .slidebar-modal-overlay {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, 0.5);
                z-index: 1000;
                display: flex;
                align-items: center;
                justify-content: center;
                animation: fadeIn 0.2s ease;
            }
            @keyframes fadeIn {
                from { opacity: 0; }
                to { opacity: 1; }
            }

            .slidebar-modal {
                border-color: var(--borderColor-default, var(--color-border-default));
                box-shadow: var(--shadow-floating-legacy, var(--color-shadow-large));
                border-radius: 8px;
                padding: 0;
                min-width: 320px;
                max-width: 420px;
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
                animation: slideUp 0.3s ease;
            }
            @keyframes slideUp {
                from { transform: translateY(20px); opacity: 0; }
                to { transform: translateY(0); opacity: 1; }
            }

            .slidebar-modal-header {
                padding: 16px 20px;
                border-bottom: 1px solid var(--color-border-default);
                font-size: 16px;
                font-weight: 600;
            }

            .slidebar-modal-body {
                padding: 20px;
            }

            .slidebar-modal-footer {
                padding: 16px 20px;
                border-top: 1px solid var(--color-border-default);
                display: flex;
                justify-content: flex-end;
                gap: 8px;
            }

            .slidebar-checkbox-label {
                display: flex;
                align-items: flex-start;
                margin-bottom: 12px;
                cursor: pointer;
                user-select: none;
            }

            .slidebar-checkbox-label input {
                margin-right: 8px;
                margin-top: 2px;
                cursor: pointer;
            }

            .slidebar-checkbox-text {
                flex: 1;
            }

            .slidebar-checkbox-title {
                font-weight: 500;
                margin-bottom: 2px;
                color: var(--color-fg-default);
            }

            .slidebar-checkbox-desc {
                font-size: 12px;
                color: var(--color-fg-muted);
            }

            .slidebar-width-control {
                display: flex;
                align-items: center;
                gap: 8px;
                margin-top: 16px;
                padding-top: 16px;
                border-top: 1px solid var(--color-border-muted);
            }

            .slidebar-width-input {
                width: 80px;
                padding: 4px 8px;
                border: 1px solid var(--color-border-default);
                border-radius: 6px;
                background: var(--color-canvas-subtle);
                color: var(--color-fg-default);
            }

            .slidebar-btn {
                padding: 5px 16px;
                border-radius: 6px;
                border: 1px solid var(--color-btn-border);
                font-size: 14px;
                font-weight: 500;
                cursor: pointer;
                transition: all 0.2s ease;
            }

            .slidebar-btn-primary {
                background: var(--color-btn-primary-bg);
                color: var(--color-btn-primary-text);
                border-color: var(--color-btn-primary-border);
            }

            .slidebar-btn-primary:hover {
                background: var(--color-btn-primary-hover-bg);
            }

            .slidebar-btn-secondary {
                background: var(--color-btn-bg);
                color: var(--color-btn-text);
            }

            .slidebar-btn-secondary:hover {
                background: var(--color-btn-hover-bg);
            }

            /* Horizontal scrolling */
            /************************/

            .slidebar-scrollable {
                overflow-x: auto !important;
                white-space: nowrap !important;
                text-overflow: initial !important;
            }

            .slidebar-scrollable::-webkit-scrollbar {
                height: 4px;
            }

            .slidebar-scrollable::-webkit-scrollbar-track {
                background: transparent;
            }

            .slidebar-scrollable::-webkit-scrollbar-thumb {
                background: var(--color-border-muted);
                border-radius: 2px;
            }
        `);
    }

    function log(message, level = 'log') {
        if (level === 'error') {
            console.error(`[${APP_NAME}]`, message);
        } else {
            console.log(`[${APP_NAME}]`, message);
        }
    }

    function cleanup() {
        if (currentObserver) {
            currentObserver.disconnect();
            currentObserver = null;
        }
        if (resizeCleanup) {
            resizeCleanup();
            resizeCleanup = null;
        }

        document
            .querySelectorAll('.slidebar-resize-handle, .slidebar-config-btn')
            .forEach((el) => el.remove());

        isInitialized = false;
        configButtonAdded = false;
        initAttempts = 0;
        configButtonAttempts = 0;
    }

    function init() {
        if (isInitialized) return;

        if (initAttempts >= MAX_INIT_ATTEMPTS) {
            log('Max initialization attempts reached. Giving up.', 'error');
            return;
        }

        initAttempts++;

        const diffLayout = document.getElementById('diff-layout-component');
        if (!diffLayout) {
            log(`Attempt ${initAttempts}: diff-layout not found, retrying...`);
            setTimeout(init, 1000);
            return;
        }

        const sidebarContainer = diffLayout.querySelector(
            '[data-target="diff-layout.sidebarContainer"]'
        );
        const mainContainer = diffLayout.querySelector(
            '[data-target="diff-layout.mainContainer"]'
        );

        if (!sidebarContainer || !mainContainer) {
            log(`Attempt ${initAttempts}: containers not found, retrying...`);
            setTimeout(init, 1000);
            return;
        }

        isInitialized = true;
        initAttempts = 0;

        applySidebarWidth(sidebarContainer, config.sidebarWidth);
        addConfigButton(sidebarContainer);

        // Set up features
        if (config.enableResizing) {
            resizeCleanup = addResizeHandle(diffLayout, sidebarContainer);
        }
        if (config.enableTooltips) {
            addTooltips(sidebarContainer);
        } else {
            removeTooltips(sidebarContainer);
        }
        if (config.enableHorizontalScroll) {
            enableHorizontalScroll(sidebarContainer);
        } else {
            disableHorizontalScroll(sidebarContainer);
        }

        // Observe for dynamic content
        observeForChanges(sidebarContainer);

        // log has 2 parameters: message and level
        let msg = `Initialized successfully with config:`;
        msg += `\n- Resizing: ${config.enableResizing}`;
        msg += `\n- Tooltips: ${config.enableTooltips}`;
        msg += `\n- Horizontal Scroll: ${config.enableHorizontalScroll}`;
        msg += `\n- Sidebar Width: ${config.sidebarWidth}px`;
        msg += `\n- Min Width: ${config.minWidth}px`;
        msg += `\n- Max Width: ${config.maxWidth}px`;
        log(msg);
    }

    function applySidebarWidth(container, width) {
        const clampedWidth = Math.max(
            config.minWidth,
            Math.min(config.maxWidth, width)
        );
        container.style.width = `${clampedWidth}px`;
        container.style.minWidth = `${clampedWidth}px`;
        container.style.flexBasis = `${clampedWidth}px`;
    }

    function addConfigButton(sidebarContainer) {
        if (configButtonAdded) {
            log('Config button already added, skipping.');
            return;
        }
        const fileTreeToggle =
            document.getElementsByTagName('file-tree-toggle')[0];
        if (!fileTreeToggle) {
            if (configButtonAttempts < 5) {
                configButtonAttempts++;
                log(
                    `Attempt ${configButtonAttempts}: file tree toggle not found, retrying...`
                );
                setTimeout(() => addConfigButton(sidebarContainer), 1000);
            } else {
                log(
                    'File tree toggle not found after multiple attempts, giving up.',
                    'error'
                );
            }
            return;
        }
        const parent = fileTreeToggle.parentElement;
        if (!parent) {
            log(
                'Parent element for file tree toggle not found, cannot add config button.',
                'error'
            );
            return;
        }
        if (parent.querySelector('.slidebar-config-btn')) {
            log('Config button already exists in the parent, skipping.');
            return;
        }

        const button = document.createElement('button');
        button.className = 'slidebar-config-btn';
        button.setAttribute('aria-label', 'Slidebar settings');
        button.setAttribute('title', 'Slidebar settings');
        button.type = 'button';
        button.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="16" height="16">
                <path fill="currentColor" d="M224,0H32C14.43,0,0,14.43,0,32v192c0,17.57,14.43,32,32,32h192c17.57,0,32-14.43,32-32V32c0-17.57-14.43-32-32-32ZM223.93,216.59H31.93v-32.92h192.13l-.13,32.92ZM224.07,177.67h-28.38v-26.79l28.38.02v26.77ZM223.93,144.38H31.93v-32.92h192.13l-.13,32.92ZM31.93,105.84v-26.79l28.38.02v26.77h-28.38ZM223.93,72.33H31.93v-32.92h192.13l-.13,32.92Z"/>
            </svg>
        `;

        button.addEventListener('click', showConfigModal);
        parent.insertBefore(button, fileTreeToggle.nextSibling);
        configButtonAdded = true;
        log('Config button added successfully');
    }

    function addResizeHandle(diffLayout, sidebarContainer) {
        // Remove any existing handle
        diffLayout.querySelector('.slidebar-resize-handle')?.remove();

        const handle = document.createElement('div');
        handle.className = 'slidebar-resize-handle';

        function updatePosition() {
            const rect = sidebarContainer.getBoundingClientRect();
            const parentRect = diffLayout.getBoundingClientRect();
            handle.style.left = `${rect.right - parentRect.left - 2}px`;
        }

        updatePosition();
        diffLayout.style.position = 'relative';
        diffLayout.appendChild(handle);

        let startX, startWidth;

        function onMouseDown(e) {
            if (e.button !== 0) return; // Only left click

            startX = e.clientX;
            startWidth = parseInt(getComputedStyle(sidebarContainer).width, 10);
            handle.classList.add('dragging');

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
            e.preventDefault();
        }

        function onMouseMove(e) {
            if (e.buttons !== 1) {
                onMouseUp();
                return;
            }

            const newWidth = Math.max(
                config.minWidth,
                Math.min(config.maxWidth, startWidth + (e.clientX - startX))
            );
            applySidebarWidth(sidebarContainer, newWidth);
            updatePosition();
        }

        function onMouseUp() {
            handle.classList.remove('dragging');
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);

            const finalWidth = parseInt(
                getComputedStyle(sidebarContainer).width,
                10
            );
            if (finalWidth !== config.sidebarWidth) {
                config.sidebarWidth = finalWidth;
                GM_setValue(STORAGE_KEY, config);
                log(`Saved new width: ${finalWidth}px`);
            }
        }

        handle.addEventListener('mousedown', onMouseDown);

        return () => {
            handle.removeEventListener('mousedown', onMouseDown);
            handle.remove();
        };
    }

    function addTooltips(container) {
        const truncated = container.querySelectorAll(
            '.ActionList-item-label--truncate'
        );
        let added = 0;

        truncated.forEach((el) => {
            if (el.scrollWidth > el.clientWidth && !el.hasAttribute('title')) {
                el.setAttribute('title', el.textContent.trim());
                added++;
            }
        });

        if (added > 0) {
            log(`Added tooltips to ${added} truncated items`);
        }
    }

    function removeTooltips(container) {
        const items = container.querySelectorAll('.ActionList-item-label');
        items.forEach((el) => {
            el.removeAttribute('title');
        });
        log(`Removed tooltips from ${items.length} items`);
    }

    function enableHorizontalScroll(container) {
        const truncated = container.querySelectorAll(
            '.ActionList-item-label--truncate'
        );

        truncated.forEach((el) => {
            el.classList.add('slidebar-scrollable');
        });
    }

    function disableHorizontalScroll(container) {
        const items = container.querySelectorAll('.slidebar-scrollable');
        // Some items may have been scrolled and would be stuck partially visible
        // unless we set scrollLeft to 0
        items.forEach((el) => {
            el.scrollLeft = 0;
            el.classList.remove('slidebar-scrollable');
        });
    }

    function observeForChanges(container) {
        if (currentObserver) {
            currentObserver.disconnect();
        }

        currentObserver = new MutationObserver((mutations) => {
            const hasRelevantChanges = mutations.some(
                (m) => m.type === 'childList' && m.addedNodes.length > 0
            );

            if (hasRelevantChanges) {
                if (config.enableTooltips) {
                    addTooltips(container);
                } else {
                    removeTooltips(container);
                }
                if (config.enableHorizontalScroll) {
                    enableHorizontalScroll(container);
                } else {
                    disableHorizontalScroll(container);
                }
            }
        });

        currentObserver.observe(container, {
            childList: true,
            subtree: true,
        });
    }

    function showConfigModal() {
        const overlay = document.createElement('div');
        overlay.className = 'slidebar-modal-overlay';

        const modal = document.createElement('div');
        modal.className = 'slidebar-modal select-menu-modal';

        modal.innerHTML = `
            <div class="slidebar-modal-header">
                Slidebar Settings
            </div>
            <div class="slidebar-modal-body">
                <label class="slidebar-checkbox-label">
                    <input type="checkbox" id="slidebar-opt-resize" ${
                        config.enableResizing ? 'checked' : ''
                    }>
                    <div class="slidebar-checkbox-text">
                        <div class="slidebar-checkbox-title">Enable Sidebar Resizing</div>
                        <div class="slidebar-checkbox-desc">Drag the edge to resize the file tree</div>
                    </div>
                </label>

                <label class="slidebar-checkbox-label">
                    <input type="checkbox" id="slidebar-opt-tooltips" ${
                        config.enableTooltips ? 'checked' : ''
                    }>
                    <div class="slidebar-checkbox-text">
                        <div class="slidebar-checkbox-title">Show Tooltips</div>
                        <div class="slidebar-checkbox-desc">Display full names on hover for truncated files</div>
                    </div>
                </label>

                <label class="slidebar-checkbox-label">
                    <input type="checkbox" id="slidebar-opt-scroll" ${
                        config.enableHorizontalScroll ? 'checked' : ''
                    }>
                    <div class="slidebar-checkbox-text">
                        <div class="slidebar-checkbox-title">Horizontal Scrolling</div>
                        <div class="slidebar-checkbox-desc">Allow scrolling long file names instead of truncating</div>
                    </div>
                </label>

                <div class="slidebar-width-control">
                    <label for="slidebar-width">Sidebar Width:</label>
                    <input type="number" id="slidebar-width" class="slidebar-width-input"
                           value="${config.sidebarWidth}" min="${
            config.minWidth
        }" max="${config.maxWidth}">
                    <span>px</span>
                </div>
            </div>
            <div class="slidebar-modal-footer">
                <button class="slidebar-btn slidebar-btn-secondary" id="slidebar-cancel">Cancel</button>
                <button class="slidebar-btn slidebar-btn-primary" id="slidebar-save">Save Changes</button>
            </div>
        `;

        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        // Focus management
        const firstInput = modal.querySelector('input');
        firstInput?.focus();

        function close() {
            overlay.remove();
        }

        function save() {
            config.enableResizing = document.getElementById(
                'slidebar-opt-resize'
            ).checked;
            config.enableTooltips = document.getElementById(
                'slidebar-opt-tooltips'
            ).checked;
            config.enableHorizontalScroll = document.getElementById(
                'slidebar-opt-scroll'
            ).checked;
            config.sidebarWidth =
                parseInt(document.getElementById('slidebar-width').value, 10) ||
                config.sidebarWidth;

            GM_setValue(STORAGE_KEY, config);
            close();

            // Reinitialize with new settings
            cleanup();
            init();
        }

        // Event handlers
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) close();
        });

        document
            .getElementById('slidebar-cancel')
            .addEventListener('click', close);
        document
            .getElementById('slidebar-save')
            .addEventListener('click', save);

        // Keyboard handling
        modal.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                close();
            } else if (e.key === 'Enter' && e.ctrlKey) {
                e.preventDefault();
                save();
            }
        });
    }

    // Handle SPA navigation
    function handleNavigation() {
        cleanup();

        // Check if we're still on a PR page
        if (location.pathname.match(/\/(pull|pulls|compare)\//)) {
            setTimeout(init, 500);
        }
    }

    // Initialize
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Listen for GitHub's SPA navigation
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            handleNavigation();
        }
    }).observe(document, { subtree: true, childList: true });
})();