Shows expanded pages path and vertical line indicator in Azure DevOps Wiki
// ==UserScript==
// @name ADO Wiki Expanded Pages Path
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Shows expanded pages path and vertical line indicator in Azure DevOps Wiki
// @author You
// @match https://dev.azure.com/*/_wiki/wikis/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
const PATH_CONTAINER_ID = 'wiki-expanded-path-container';
const VERTICAL_LINE_CLASS = 'wiki-expanded-vertical-line';
const ANCHOR_PAGE_NAME = 'Service Enablement'; // Hardcoded anchor
const TOGGLE_BTN_ID = 'wiki-sidebar-toggle-btn';
let updateTimeout = null;
let savedSidebarWidth = null;
let isSidebarCollapsed = false;
const SIDEBAR_STATE_KEY = 'wiki-sidebar-collapsed';
const LEVEL_COLORS = {
1: { bright: '#0078d4', dim: 'rgba(0, 120, 212, 0.2)' },
2: { bright: '#00bcf2', dim: 'rgba(0, 188, 242, 0.2)' },
3: { bright: '#00b294', dim: 'rgba(0, 178, 148, 0.2)' },
4: { bright: '#8cbd18', dim: 'rgba(139, 189, 24, 0.2)' },
5: { bright: '#f7630c', dim: 'rgba(247, 99, 12, 0.2)' },
6: { bright: '#e81123', dim: 'rgba(232, 17, 35, 0.2)' },
7: { bright: '#b146c2', dim: 'rgba(177, 70, 194, 0.2)' },
8: { bright: '#8764b8', dim: 'rgba(135, 100, 184, 0.2)' },
9: { bright: '#744da9', dim: 'rgba(116, 77, 169, 0.2)' },
10: { bright: '#5c2d91', dim: 'rgba(92, 45, 145, 0.2)' }
};
const styles = `
/* Path breadcrumb styles */
#${PATH_CONTAINER_ID} {
background: linear-gradient(to right, #f3f2f1, #ffffff);
border-bottom: 1px solid #e1dfdd;
padding: 8px 12px;
font-size: 12px;
color: #323130;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
min-height: 32px;
box-sizing: border-box;
}
#${PATH_CONTAINER_ID} .path-label {
font-weight: 600;
color: #605e5c;
margin-right: 8px;
}
#${PATH_CONTAINER_ID} .path-item {
display: inline-flex;
align-items: center;
background: #ffffff;
border: 1px solid #e1dfdd;
border-radius: 3px;
padding: 2px 8px;
color: #0078d4;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
#${PATH_CONTAINER_ID} .path-item:hover {
background: #f3f2f1;
border-color: #0078d4;
}
#${PATH_CONTAINER_ID} .path-item.selected {
background: #0078d4;
color: #ffffff;
border-color: #0078d4;
font-weight: 600;
}
#${PATH_CONTAINER_ID} .path-separator {
color: #a19f9d;
font-size: 10px;
}
#${PATH_CONTAINER_ID}.empty-path::before {
content: 'No expanded pages';
color: #a19f9d;
font-style: italic;
}
/* Vertical line styles */
.${VERTICAL_LINE_CLASS} {
position: absolute;
bottom: 0;
width: 2px;
pointer-events: none;
z-index: 1;
}
/* Make sure rows have relative positioning for the line */
tr.bolt-tree-row {
position: relative;
}
/* Sidebar collapse/expand toggle button */
#${TOGGLE_BTN_ID} {
position: absolute;
top: 50%;
right: -14px;
transform: translateY(-50%);
width: 14px;
height: 40px;
background: #f3f2f1;
border: 1px solid #e1dfdd;
border-left: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 0;
transition: background 0.15s ease;
color: #605e5c;
font-size: 10px;
line-height: 1;
}
#${TOGGLE_BTN_ID}:hover {
background: #e1dfdd;
color: #0078d4;
}
#${TOGGLE_BTN_ID} .toggle-icon {
display: block;
pointer-events: none;
}
/* Sidebar wrapper for positioning the toggle */
.wiki-sidebar-wrapper {
position: relative;
}
/* Smooth transition for sidebar collapse */
.wiki-sidebar-collapsible {
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.wiki-sidebar-collapsed {
width: 0px !important;
min-width: 0px !important;
overflow: hidden !important;
}
.wiki-sidebar-collapsed > *:not(#${TOGGLE_BTN_ID}):not(.wiki-sidebar-toggle-anchor) {
visibility: hidden;
}
`;
function injectStyles() {
if (document.getElementById('wiki-path-styles')) return;
const style = document.createElement('style');
style.id = 'wiki-path-styles';
style.textContent = styles;
document.head.appendChild(style);
}
function createPathContainer() {
let container = document.getElementById(PATH_CONTAINER_ID);
if (container) return container;
container = document.createElement('div');
container.id = PATH_CONTAINER_ID;
const treePane = document.querySelector('.wiki-tree-pane');
if (treePane && treePane.parentNode) {
treePane.parentNode.insertBefore(container, treePane);
}
return container;
}
function getSelectedRowIndex(rows) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].classList.contains('selected')) {
return i;
}
}
return -1;
}
function getExpandedPath() {
const pages = [];
const rows = document.querySelectorAll('tr.bolt-tree-row');
for (const row of rows) {
const isExpanded = row.getAttribute('aria-expanded') === 'true';
const isSelected = row.classList.contains('selected');
if (!isExpanded && !isSelected) continue;
const level = parseInt(row.getAttribute('aria-level'), 10);
const nameEl = row.querySelector('.tree-name-cell.tree-name-text');
if (!nameEl) continue;
pages.push({
name: nameEl.textContent.trim(),
level,
isSelected,
isExpanded,
element: row
});
}
pages.sort((a, b) => a.level - b.level);
const path = [];
for (const page of pages) {
while (path.length > 0 && path[path.length - 1].level >= page.level) {
path.pop();
}
path.push(page);
}
return path;
}
function clearVerticalLines() {
document.querySelectorAll(`.${VERTICAL_LINE_CLASS}`).forEach(el => el.remove());
}
function getLineLeftPosition(level) {
const baseOffset = 24;
const levelOffset = 16;
return baseOffset + (level - 1) * levelOffset;
}
function findAnchorLevel() {
const rows = document.querySelectorAll('tr.bolt-tree-row');
for (const row of rows) {
const nameEl = row.querySelector('.tree-name-cell.tree-name-text');
if (nameEl && nameEl.textContent.trim() === ANCHOR_PAGE_NAME) {
const isExpanded = row.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
return parseInt(row.getAttribute('aria-level'), 10);
}
}
}
return -1; // Anchor not found or not expanded
}
function addVerticalLines() {
clearVerticalLines();
const rows = Array.from(document.querySelectorAll('tr.bolt-tree-row'));
if (rows.length === 0) return;
// Find the anchor level (Service Enablement)
const anchorLevel = findAnchorLevel();
// For each expanded row, draw lines down through its descendants
rows.forEach((row, index) => {
const isExpanded = row.getAttribute('aria-expanded') === 'true';
if (!isExpanded) return;
const level = parseInt(row.getAttribute('aria-level'), 10);
// Find last descendant
let lastDescendantIndex = index;
for (let i = index + 1; i < rows.length; i++) {
const childLevel = parseInt(rows[i].getAttribute('aria-level'), 10);
if (childLevel <= level) break;
lastDescendantIndex = i;
}
// Determine if this ENTIRE line should be dimmed:
// If anchor is found and expanded:
// - Levels <= anchor level → dimmed (parents/ancestors + anchor itself)
// - Levels > anchor level → bright (descendants only)
// If anchor is not found/expanded, everything is bright
const shouldDim = anchorLevel >= 0 && level <= anchorLevel;
const colors = LEVEL_COLORS[level] || LEVEL_COLORS[10];
const lineColor = shouldDim ? colors.dim : colors.bright;
// Add line to each row from this expanded row to its last descendant
for (let i = index; i <= lastDescendantIndex; i++) {
const targetRow = rows[i];
const isFirstRow = (i === index);
// Check if line for this level already exists
if (targetRow.querySelector(`.${VERTICAL_LINE_CLASS}[data-level="${level}"]`)) {
continue;
}
const line = document.createElement('div');
line.className = VERTICAL_LINE_CLASS;
line.setAttribute('data-level', level);
line.style.left = `${getLineLeftPosition(level)}px`;
line.style.background = lineColor;
if (isFirstRow) {
line.style.top = '50%';
line.style.bottom = '0';
} else {
line.style.top = '0';
line.style.bottom = '0';
}
targetRow.style.position = 'relative';
targetRow.appendChild(line);
}
});
}
function updatePathDisplay() {
const container = createPathContainer();
if (!container) return;
const pages = getExpandedPath();
container.innerHTML = '';
container.classList.toggle('empty-path', pages.length === 0);
if (pages.length === 0) return;
const label = document.createElement('span');
label.className = 'path-label';
label.textContent = '📂 Path:';
container.appendChild(label);
pages.forEach((page, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.className = 'path-separator';
sep.textContent = ' › ';
container.appendChild(sep);
}
const item = document.createElement('span');
item.className = 'path-item' + (page.isSelected ? ' selected' : '');
item.textContent = page.name;
item.title = `Level ${page.level}: ${page.name}`;
item.onclick = () => page.element.click();
container.appendChild(item);
});
}
function updateAll() {
updatePathDisplay();
addVerticalLines();
}
function scheduleUpdate() {
if (updateTimeout) return;
updateTimeout = setTimeout(() => {
updateTimeout = null;
updateAll();
}, 150);
}
function getSidebarElement() {
// The sidebar is the splitter pane that contains .wiki-tree-pane
const treePane = document.querySelector('.wiki-tree-pane');
if (!treePane) return null;
// Walk up to find the splitter's left pane (the resizable sidebar container)
let el = treePane.closest('.bolt-splitter-main') || treePane.closest('[class*="splitter"]');
if (el) return el;
// Fallback: use the parent that has an explicit width style (the resizable pane)
el = treePane.parentElement;
while (el) {
if (el.style && el.style.width) return el;
if (el.classList && (el.classList.contains('left-hub-content') || el.classList.contains('wiki-nav-content'))) return el;
el = el.parentElement;
}
// Last resort: direct parent of treePane
return treePane.parentElement;
}
function createToggleButton() {
if (document.getElementById(TOGGLE_BTN_ID)) return;
const sidebar = getSidebarElement();
if (!sidebar) return;
// Ensure sidebar is positioned for the absolute toggle button
const computedPos = window.getComputedStyle(sidebar).position;
if (computedPos === 'static') {
sidebar.style.position = 'relative';
}
const btn = document.createElement('button');
btn.id = TOGGLE_BTN_ID;
btn.title = 'Collapse sidebar';
btn.innerHTML = '<span class="toggle-icon">◀</span>';
btn.addEventListener('click', toggleSidebar);
sidebar.appendChild(btn);
}
function toggleSidebar() {
const sidebar = getSidebarElement();
if (!sidebar) return;
const btn = document.getElementById(TOGGLE_BTN_ID);
const icon = btn ? btn.querySelector('.toggle-icon') : null;
if (!isSidebarCollapsed) {
// Collapse: save current width, then collapse
savedSidebarWidth = sidebar.style.width || sidebar.getBoundingClientRect().width + 'px';
sidebar.classList.add('wiki-sidebar-collapsible');
// Force a reflow so the transition applies
sidebar.getBoundingClientRect();
sidebar.classList.add('wiki-sidebar-collapsed');
// Handle the splitter gutter/handle if present
const splitterGutter = sidebar.parentElement?.querySelector('.bolt-splitter-gutter, [class*="splitter-gutter"], [class*="handleBar"]');
if (splitterGutter) {
splitterGutter.style.display = 'none';
}
if (icon) icon.textContent = '▶';
if (btn) btn.title = 'Expand sidebar';
isSidebarCollapsed = true;
localStorage.setItem(SIDEBAR_STATE_KEY, 'true');
// Move button outside so it's still visible
repositionToggleWhenCollapsed(sidebar, btn);
} else {
// Expand: restore saved width
sidebar.classList.remove('wiki-sidebar-collapsed');
if (savedSidebarWidth) {
sidebar.style.width = savedSidebarWidth;
}
// Restore splitter gutter
const splitterGutter = sidebar.parentElement?.querySelector('.bolt-splitter-gutter, [class*="splitter-gutter"], [class*="handleBar"]');
if (splitterGutter) {
splitterGutter.style.display = '';
}
if (icon) icon.textContent = '◀';
if (btn) btn.title = 'Collapse sidebar';
isSidebarCollapsed = false;
localStorage.removeItem(SIDEBAR_STATE_KEY);
// Move button back into sidebar
repositionToggleWhenExpanded(sidebar, btn);
// Remove transition class after animation finishes
setTimeout(() => {
sidebar.classList.remove('wiki-sidebar-collapsible');
}, 250);
// Re-render lines after expand
setTimeout(updateAll, 300);
}
}
function repositionToggleWhenCollapsed(sidebar, btn) {
if (!btn) return;
// Move button to the sidebar's parent so it remains visible when sidebar width is 0
const parent = sidebar.parentElement;
if (parent && btn.parentElement !== parent) {
btn.remove();
parent.style.position = 'relative';
btn.style.position = 'absolute';
btn.style.left = '0px';
btn.style.right = 'auto';
parent.insertBefore(btn, parent.firstChild);
}
}
function repositionToggleWhenExpanded(sidebar, btn) {
if (!btn) return;
// Move button back into sidebar
if (btn.parentElement !== sidebar) {
btn.remove();
btn.style.position = 'absolute';
btn.style.left = '';
btn.style.right = '-14px';
sidebar.appendChild(btn);
}
}
function init() {
const treePane = document.querySelector('.wiki-tree-pane');
if (!treePane) {
setTimeout(init, 500);
return;
}
injectStyles();
createPathContainer();
createToggleButton();
// Restore sidebar collapsed state from previous session
if (localStorage.getItem(SIDEBAR_STATE_KEY) === 'true') {
toggleSidebar();
}
updateAll();
treePane.addEventListener('click', scheduleUpdate);
treePane.addEventListener('keydown', (e) => {
if (['Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
scheduleUpdate();
}
});
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(updateAll, 300);
}
});
urlObserver.observe(document.body, { childList: true, subtree: true });
const tableContainer = document.querySelector('.bolt-table-container');
if (tableContainer) {
let scrollTimeout = null;
tableContainer.addEventListener('scroll', () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
addVerticalLines();
}, 100);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();