diff --git a/css/trailSidebar.css b/css/trailSidebar.css new file mode 100644 index 000000000..70fc0df2a --- /dev/null +++ b/css/trailSidebar.css @@ -0,0 +1,229 @@ +/* Trail Sidebar - Tree view of browsing trails */ + +.trail-sidebar { + position: fixed; + left: 0; + top: calc(36px + var(--control-space-top, 0px)); + width: 280px; + height: calc(100% - 36px - var(--control-space-top, 0px)); + background-color: #fff; + border-right: 1px solid rgba(0, 0, 0, 0.1); + overflow-y: auto; + z-index: 100; + display: flex; + flex-direction: column; + transition: transform 0.15s ease-out; +} + +.dark-mode .trail-sidebar { + background-color: rgb(33, 37, 43); + border-right-color: rgba(255, 255, 255, 0.1); +} + +.trail-sidebar[hidden] { + display: none; +} + +/* Adjust webviews when sidebar is visible */ +body.trail-sidebar-visible #webviews { + margin-left: 280px; + width: calc(100% - 280px); +} + +body.trail-sidebar-visible #searchbar { + margin-left: 280px; + width: calc(100% - 280px); +} + +/* Sidebar Header */ +.trail-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + flex-shrink: 0; +} + +.dark-mode .trail-sidebar-header { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +.trail-sidebar-title { + font-size: 13px; + font-weight: 600; + opacity: 0.8; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.trail-sidebar-close { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.1s, background-color 0.1s; +} + +.trail-sidebar-close:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.08); +} + +.dark-mode .trail-sidebar-close:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +/* Tree Container */ +.trail-tree { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +/* Trail Node */ +.trail-node { + padding: 6px 12px; + padding-left: calc(12px + var(--depth, 0) * 16px); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: inherit; + transition: background-color 0.1s; + user-select: none; +} + +.trail-node:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.dark-mode .trail-node:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.trail-node.selected { + background-color: rgba(0, 117, 255, 0.12); +} + +.dark-mode .trail-node.selected { + background-color: rgba(30, 144, 255, 0.2); +} + +.trail-node.selected:hover { + background-color: rgba(0, 117, 255, 0.18); +} + +.dark-mode .trail-node.selected:hover { + background-color: rgba(30, 144, 255, 0.25); +} + +/* Collapse Button */ +.trail-collapse-btn { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + opacity: 0.6; + flex-shrink: 0; + transition: opacity 0.1s, transform 0.15s; +} + +.trail-collapse-btn:not(.trail-collapse-btn-leaf):hover { + opacity: 1; +} + +.trail-collapse-btn-leaf { + opacity: 0.3; + font-size: 8px; +} + +/* Favicon */ +.trail-favicon { + width: 16px; + height: 16px; + flex-shrink: 0; + border-radius: 2px; +} + +/* Title */ +.trail-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.9; +} + +.trail-node.selected .trail-title { + opacity: 1; + font-weight: 500; +} + +/* Scrollbar styling */ +.trail-tree::-webkit-scrollbar { + width: 6px; +} + +.trail-tree::-webkit-scrollbar-track { + background-color: transparent; +} + +.trail-tree::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} + +.trail-tree::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.25); +} + +.dark-mode .trail-tree::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.2); +} + +.dark-mode .trail-tree::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.3); +} + +/* Depth visual guides (optional lines) */ +.trail-node::before { + content: ''; + position: absolute; + left: calc(12px + (var(--depth, 0) - 1) * 16px + 7px); + top: 0; + bottom: 0; + width: 1px; + background-color: rgba(0, 0, 0, 0.08); + display: none; +} + +.dark-mode .trail-node::before { + background-color: rgba(255, 255, 255, 0.08); +} + +/* Show guide lines for depth > 0 */ +.trail-node[style*="--depth: 1"]::before, +.trail-node[style*="--depth: 2"]::before, +.trail-node[style*="--depth: 3"]::before, +.trail-node[style*="--depth: 4"]::before, +.trail-node[style*="--depth: 5"]::before { + display: none; /* Disabled for now - can enable for tree lines */ +} + +/* Empty state */ +.trail-tree:empty::after { + content: 'No tabs open'; + display: block; + padding: 20px; + text-align: center; + opacity: 0.5; + font-size: 13px; +} diff --git a/js/browserUI.js b/js/browserUI.js index 05bba6a27..3a7842b58 100644 --- a/js/browserUI.js +++ b/js/browserUI.js @@ -10,6 +10,7 @@ var focusMode = require('focusMode.js') var tabBar = require('navbar/tabBar.js') var tabEditor = require('navbar/tabEditor.js') var searchbar = require('searchbar/searchbar.js') +var trailSidebar = require('trailSidebar/trailSidebar.js') /* creates a new task */ @@ -300,6 +301,9 @@ tabBar.events.on('tab-closed', function (id) { closeTab(id) }) +// Initialize trail sidebar +trailSidebar.initialize() + module.exports = { addTask, addTab, @@ -310,5 +314,6 @@ module.exports = { switchToTask, switchToTab, moveTabLeft, - moveTabRight + moveTabRight, + toggleTrailSidebar: () => trailSidebar.toggle() } diff --git a/js/defaultKeybindings.js b/js/defaultKeybindings.js index f1286334d..9d0241bae 100644 --- a/js/defaultKeybindings.js +++ b/js/defaultKeybindings.js @@ -285,6 +285,15 @@ const defaultKeybindings = { }) } }) + + // Toggle trail sidebar (Cmd+\ or Cmd+B) + keybindings.defineShortcut({ keys: 'mod+\\' }, function () { + browserUI.toggleTrailSidebar() + }) + + keybindings.defineShortcut('toggleTrailSidebar', function () { + browserUI.toggleTrailSidebar() + }) } } diff --git a/js/tabState/tab.js b/js/tabState/tab.js index bbde289a5..a981276bc 100644 --- a/js/tabState/tab.js +++ b/js/tabState/tab.js @@ -24,11 +24,18 @@ class TabList { scrollPosition: tab.scrollPosition || 0, selected: tab.selected || false, muted: tab.muted || false, - loaded: tab.loaded || false, + loaded: tab.loaded || false, hasAudio: false, previewImage: '', isFileView: false, hasWebContents: false, + // Trail tree properties + parentId: tab.parentId || null, // ID of parent tab (null = root) + childIds: tab.childIds || [], // Array of child tab IDs + collapsed: tab.collapsed || false, // Whether children are hidden + trailName: tab.trailName || null, // Optional name for this branch + trailEmoji: tab.trailEmoji || null, // Optional emoji + depth: tab.depth || 0, // Nesting level for indentation } if (options.atEnd) { @@ -73,6 +80,39 @@ class TabList { if (index < 0) return false const containingTask = this.parentTaskList.getTaskContainingTab(id).id + const tab = this.tabs[index] + + // Handle children: reparent them to the destroyed tab's parent (grandparent) + const childIds = tab.childIds || [] + const parentId = tab.parentId || null + + childIds.forEach(childId => { + const childIndex = this.getIndex(childId) + if (childIndex >= 0) { + // Update child's parentId to grandparent + this.tabs[childIndex].parentId = parentId + // Recalculate depth + this.tabs[childIndex].depth = this.parentTaskList.calculateDepth(childId) + + if (emit) { + this.parentTaskList.emit('tab-reparented', childId, parentId, containingTask) + } + } + }) + + // If this tab had a parent, remove this tab from parent's childIds + if (parentId) { + const parentIndex = this.getIndex(parentId) + if (parentIndex >= 0) { + const parentChildIds = this.tabs[parentIndex].childIds || [] + const childIdIndex = parentChildIds.indexOf(id) + if (childIdIndex >= 0) { + parentChildIds.splice(childIdIndex, 1) + } + // Add this tab's children to the parent's childIds + this.tabs[parentIndex].childIds = parentChildIds.concat(childIds) + } + } tasks.getTaskContainingTab(id).tabHistory.push(this.toPermanentState(this.tabs[index])) this.tabs.splice(index, 1) @@ -188,7 +228,7 @@ class TabList { splice (...args) { const containingTask = this.parentTaskList.find(t => t.tabs === this).id - + this.parentTaskList.emit('tab-splice', containingTask, ...args) return this.tabs.splice.apply(this.tabs, args) } @@ -204,7 +244,7 @@ class TabList { Object.keys(tab) .filter(key => !TabList.temporaryProperties.includes(key)) .forEach(key => result[key] = tab[key]) - + return result } diff --git a/js/tabState/task.js b/js/tabState/task.js index c98f11a99..badd080f5 100644 --- a/js/tabState/task.js +++ b/js/tabState/task.js @@ -194,6 +194,163 @@ class TaskList { static getRandomId () { return Math.round(Math.random() * 100000000000000000) } + + // ===== Trail Tree Methods ===== + + // Get direct children of a tab + getChildren (tabId) { + const task = this.getTaskContainingTab(tabId) + if (!task) return [] + + const tab = task.tabs.get(tabId) + if (!tab) return [] + + return (tab.childIds || []).map(childId => task.tabs.get(childId)).filter(Boolean) + } + + // Get all descendants (recursive) + getDescendants (tabId) { + const descendants = [] + const children = this.getChildren(tabId) + + children.forEach(child => { + descendants.push(child) + descendants.push(...this.getDescendants(child.id)) + }) + + return descendants + } + + // Get ancestors (path to root), ordered from immediate parent to root + getAncestors (tabId) { + const ancestors = [] + const task = this.getTaskContainingTab(tabId) + if (!task) return ancestors + + let currentTab = task.tabs.get(tabId) + + while (currentTab && currentTab.parentId) { + const parent = task.tabs.get(currentTab.parentId) + if (parent) { + ancestors.push(parent) + currentTab = parent + } else { + break + } + } + + return ancestors + } + + // Move tab to new parent + reparent (tabId, newParentId, emit = true) { + const task = this.getTaskContainingTab(tabId) + if (!task) return false + + const tabIndex = task.tabs.getIndex(tabId) + if (tabIndex < 0) return false + + const tab = task.tabs.tabs[tabIndex] + const oldParentId = tab.parentId + + // Remove from old parent's childIds + if (oldParentId) { + const oldParentIndex = task.tabs.getIndex(oldParentId) + if (oldParentIndex >= 0) { + const oldParent = task.tabs.tabs[oldParentIndex] + const childIndex = (oldParent.childIds || []).indexOf(tabId) + if (childIndex >= 0) { + oldParent.childIds.splice(childIndex, 1) + } + } + } + + // Update tab's parentId + tab.parentId = newParentId + + // Add to new parent's childIds + if (newParentId) { + const newParentIndex = task.tabs.getIndex(newParentId) + if (newParentIndex >= 0) { + const newParent = task.tabs.tabs[newParentIndex] + if (!newParent.childIds) newParent.childIds = [] + newParent.childIds.push(tabId) + } + } + + // Recalculate depth for this tab and all descendants + this.recalculateDepthRecursive(tabId) + + if (emit) { + this.emit('tab-reparented', tabId, newParentId, task.id) + } + + return true + } + + // Recursively recalculate depth for a tab and its descendants + recalculateDepthRecursive (tabId) { + const task = this.getTaskContainingTab(tabId) + if (!task) return + + const tabIndex = task.tabs.getIndex(tabId) + if (tabIndex < 0) return + + const tab = task.tabs.tabs[tabIndex] + tab.depth = this.calculateDepth(tabId) + + // Recalculate for all children + const childIds = tab.childIds || [] + childIds.forEach(childId => this.recalculateDepthRecursive(childId)) + } + + // Collapse tab (hide children) + collapseTab (tabId, emit = true) { + const task = this.getTaskContainingTab(tabId) + if (!task) return false + + const tabIndex = task.tabs.getIndex(tabId) + if (tabIndex < 0) return false + + task.tabs.tabs[tabIndex].collapsed = true + + if (emit) { + this.emit('tab-collapsed', tabId, task.id) + } + + return true + } + + // Expand tab (show children) + expandTab (tabId, emit = true) { + const task = this.getTaskContainingTab(tabId) + if (!task) return false + + const tabIndex = task.tabs.getIndex(tabId) + if (tabIndex < 0) return false + + task.tabs.tabs[tabIndex].collapsed = false + + if (emit) { + this.emit('tab-expanded', tabId, task.id) + } + + return true + } + + // Get root tabs (tabs with no parent) in a task + getRootTabs (taskId) { + const task = this.get(taskId) + if (!task) return [] + + return task.tabs.get().filter(tab => !tab.parentId) + } + + // Calculate depth from ancestors + calculateDepth (tabId) { + const ancestors = this.getAncestors(tabId) + return ancestors.length + } } module.exports = TaskList diff --git a/js/trailSidebar/trailSidebar.js b/js/trailSidebar/trailSidebar.js new file mode 100644 index 000000000..71d9be4db --- /dev/null +++ b/js/trailSidebar/trailSidebar.js @@ -0,0 +1,204 @@ +const webviews = require('webviews.js') +const urlParser = require('util/urlParser.js') + +const trailSidebar = { + container: null, + visible: false, + collapsedNodes: new Set(), // Track which nodes are collapsed in the UI + + initialize () { + // Create sidebar container + this.container = document.createElement('div') + this.container.className = 'trail-sidebar' + this.container.id = 'trail-sidebar' + this.container.setAttribute('hidden', '') + + // Add header + const header = document.createElement('div') + header.className = 'trail-sidebar-header' + + const headerTitle = document.createElement('span') + headerTitle.className = 'trail-sidebar-title' + headerTitle.textContent = 'Trail' + header.appendChild(headerTitle) + + const closeBtn = document.createElement('button') + closeBtn.className = 'trail-sidebar-close i carbon:close' + closeBtn.addEventListener('click', () => this.hide()) + header.appendChild(closeBtn) + + this.container.appendChild(header) + + // Add tree container + const treeContainer = document.createElement('div') + treeContainer.className = 'trail-tree' + treeContainer.id = 'trail-tree' + this.container.appendChild(treeContainer) + + // Insert into DOM (after navbar, before webviews) + const webviewsEl = document.getElementById('webviews') + document.body.insertBefore(this.container, webviewsEl) + + // Listen for tab events to update tree + this.setupEventListeners() + }, + + setupEventListeners () { + // Update on tab changes + tasks.on('tab-added', () => this.render()) + tasks.on('tab-destroyed', () => this.render()) + tasks.on('tab-selected', () => this.render()) + tasks.on('tab-updated', (id, key) => { + if (['title', 'url'].includes(key)) { + this.render() + } + }) + tasks.on('tab-reparented', () => this.render()) + tasks.on('tab-collapsed', () => this.render()) + tasks.on('tab-expanded', () => this.render()) + tasks.on('task-selected', () => this.render()) + }, + + render () { + if (!this.visible) return + + const treeContainer = document.getElementById('trail-tree') + if (!treeContainer) return + + // Clear existing content + treeContainer.innerHTML = '' + + const currentTask = tasks.getSelected() + if (!currentTask) return + + // Get all root tabs (no parent) + const rootTabs = tasks.getRootTabs(currentTask.id) + const selectedTabId = tabs.getSelected() + + // Render each root and its descendants + rootTabs.forEach(rootTab => { + this.renderNode(rootTab, 0, treeContainer, selectedTabId) + }) + }, + + renderNode (tab, depth, container, selectedTabId) { + const nodeEl = document.createElement('div') + nodeEl.className = 'trail-node' + nodeEl.setAttribute('data-tab-id', tab.id) + nodeEl.style.setProperty('--depth', depth) + + if (tab.id === selectedTabId) { + nodeEl.classList.add('selected') + } + + // Get children for this tab + const children = tasks.getChildren(tab.id) + const hasChildren = children.length > 0 + const isCollapsed = tab.collapsed || this.collapsedNodes.has(tab.id) + + // Collapse/expand button + const collapseBtn = document.createElement('span') + collapseBtn.className = 'trail-collapse-btn' + if (hasChildren) { + collapseBtn.textContent = isCollapsed ? '▶' : '▼' + collapseBtn.addEventListener('click', (e) => { + e.stopPropagation() + this.toggleNodeCollapse(tab.id) + }) + } else { + collapseBtn.textContent = '•' + collapseBtn.classList.add('trail-collapse-btn-leaf') + } + nodeEl.appendChild(collapseBtn) + + // Favicon + const favicon = document.createElement('img') + favicon.className = 'trail-favicon' + const domain = urlParser.getDomain(tab.url) + if (domain && !urlParser.isInternalURL(tab.url)) { + favicon.src = `https://www.google.com/s2/favicons?domain=${domain}&sz=16` + favicon.onerror = () => { + favicon.style.display = 'none' + } + } else { + favicon.style.display = 'none' + } + nodeEl.appendChild(favicon) + + // Title + const titleEl = document.createElement('span') + titleEl.className = 'trail-title' + + let title = tab.title || tab.url || 'New Tab' + // Truncate long titles + if (title.length > 40) { + title = title.substring(0, 40) + '…' + } + titleEl.textContent = title + titleEl.title = tab.title || tab.url || 'New Tab' // Full title on hover + nodeEl.appendChild(titleEl) + + // Click handler to switch tabs + nodeEl.addEventListener('click', () => { + const browserUI = require('browserUI.js') + browserUI.switchToTab(tab.id) + }) + + container.appendChild(nodeEl) + + // Render children if not collapsed + if (hasChildren && !isCollapsed) { + children.forEach(child => { + this.renderNode(child, depth + 1, container, selectedTabId) + }) + } + }, + + toggleNodeCollapse (tabId) { + const task = tasks.getTaskContainingTab(tabId) + if (!task) return + + const tab = tabs.get(tabId) + if (!tab) return + + // Toggle collapsed state + if (tab.collapsed) { + tasks.expandTab(tabId) + this.collapsedNodes.delete(tabId) + } else { + tasks.collapseTab(tabId) + this.collapsedNodes.add(tabId) + } + }, + + show () { + if (!this.container) return + + this.visible = true + this.container.removeAttribute('hidden') + document.body.classList.add('trail-sidebar-visible') + this.render() + }, + + hide () { + if (!this.container) return + + this.visible = false + this.container.setAttribute('hidden', '') + document.body.classList.remove('trail-sidebar-visible') + }, + + toggle () { + if (this.visible) { + this.hide() + } else { + this.show() + } + }, + + isVisible () { + return this.visible + } +} + +module.exports = trailSidebar diff --git a/scripts/buildBrowserStyles.js b/scripts/buildBrowserStyles.js index ded9b3145..85a888078 100644 --- a/scripts/buildBrowserStyles.js +++ b/scripts/buildBrowserStyles.js @@ -20,6 +20,7 @@ const modules = [ 'css/passwordManager.css', 'css/passwordCapture.css', 'css/passwordViewer.css', + 'css/trailSidebar.css', 'node_modules/dragula/dist/dragula.min.css' ]