10 KiB
Plan: External URLs Feature
Context
User wants to manage bookmarked external URLs in HiveOps Browser:
- A new "External URLs" tab in the Settings window to add/remove named URL entries
- A "External URLs" menu item in the application menu listing those URLs
- Clicking a URL from the menu opens it in a new tab within the main window (not a new OS window)
Architecture Decision: BrowserView Tab Bar
The main window uses BrowserViews (no parent HTML). Adding in-window tabs requires:
- A
tabbarViewBrowserView (36px tall) at the top of the main window — visible only when at least one external tab is open - Each external URL tab gets its own
BrowserViewwhen opened, persisted inexternalViewsMap updateBounds()is updated to account for the tab bar height- The "home" tab always uses the existing
incidentView
Data Structure (stored via electron-store)
"externalUrls": [
{ "id": "uuid-1", "name": "My Dashboard", "url": "https://example.com" }
]
IDs are generated with crypto.randomUUID().
Files to Modify
1. src/main/config.js
- Add
externalUrls: []todefaultConfig - Add
externalUrlstogetAll()return object - Add to
setAll():if (settings.externalUrls !== undefined) store.set('externalUrls', settings.externalUrls) - Add convenience methods:
getExternalUrls()→store.get('externalUrls', [])setExternalUrls(urls)→store.set('externalUrls', urls)
2. src/main/main.js
New variables (top of file):
let tabbarView = null;
const externalViews = new Map(); // tabId → BrowserView
let activeTabId = 'home';
Modify updateBounds():
function updateBounds() {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!incidentView || !statusbarView) return;
const STATUS_BAR_H = 32;
const TAB_BAR_H = (tabbarView && externalViews.size > 0) ? 36 : 0;
const [w, h] = mainWindow.getContentSize();
if (tabbarView) {
tabbarView.setBounds({ x: 0, y: 0, width: TAB_BAR_H > 0 ? w : 0, height: TAB_BAR_H });
}
const contentH = h - STATUS_BAR_H - TAB_BAR_H;
// Show only active view; hide others by setting zero bounds
[['home', incidentView], ...externalViews].forEach(([tid, view]) => {
if (view && !view.webContents.isDestroyed()) {
if (tid === activeTabId) {
view.setBounds({ x: 0, y: TAB_BAR_H, width: w, height: contentH });
} else {
view.setBounds({ x: 0, y: TAB_BAR_H, width: 0, height: 0 });
}
}
});
statusbarView.setBounds({ x: 0, y: h - STATUS_BAR_H, width: w, height: STATUS_BAR_H });
}
Modify createMainWindow() — after creating incidentView and statusbarView, create tabbarView:
tabbarView = new BrowserView({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
});
mainWindow.addBrowserView(tabbarView);
tabbarView.webContents.loadFile(path.join(__dirname, '../renderer/tabbar.html'));
Also add tabbarView cleanup in the closed event.
Add openExternalTab(id, name, url) function:
function openExternalTab(id, name, url) {
if (externalViews.has(id)) {
switchToTab(id);
return;
}
const view = new BrowserView({ webPreferences: { nodeIntegration: false, contextIsolation: true } });
mainWindow.addBrowserView(view);
externalViews.set(id, view);
view.webContents.loadURL(url);
switchToTab(id);
}
Add switchToTab(tabId) function:
function switchToTab(tabId) {
activeTabId = tabId;
updateBounds();
sendTabsToTabbar();
}
Add closeExternalTab(tabId) function:
function closeExternalTab(tabId) {
const view = externalViews.get(tabId);
if (view) {
mainWindow.removeBrowserView(view);
view.webContents.destroy();
externalViews.delete(tabId);
}
if (activeTabId === tabId) {
activeTabId = 'home';
}
updateBounds();
sendTabsToTabbar();
}
Add sendTabsToTabbar() function:
function sendTabsToTabbar() {
if (!tabbarView || tabbarView.webContents.isDestroyed()) return;
const tabs = [
{ id: 'home', name: 'HiveOps', closeable: false }
];
for (const [id] of externalViews) {
const urlEntry = config.getExternalUrls().find(u => u.id === id);
tabs.push({ id, name: urlEntry ? urlEntry.name : id, closeable: true });
}
tabbarView.webContents.send('update-tabs', { tabs, activeTabId });
}
Modify createMenu() — add "External URLs" menu after the existing File menu (or as a top-level menu):
{
label: 'External URLs',
submenu: buildExternalUrlsSubmenu()
}
Add buildExternalUrlsSubmenu() function:
function buildExternalUrlsSubmenu() {
const urls = config.getExternalUrls();
if (urls.length === 0) {
return [
{ label: 'No URLs configured', enabled: false },
{ type: 'separator' },
{ label: 'Manage External URLs...', click: () => openSettings() }
];
}
return [
...urls.map(entry => ({
label: entry.name,
click: () => openExternalTab(entry.id, entry.name, entry.url)
})),
{ type: 'separator' },
{ label: 'Manage External URLs...', click: () => openSettings() }
];
}
Add IPC handlers:
ipcMain.handle('get-external-urls', () => config.getExternalUrls());
ipcMain.handle('save-external-urls', (event, urls) => {
config.setExternalUrls(urls);
createMenu(); // rebuild menu with updated URLs
return config.getExternalUrls();
});
ipcMain.on('switch-tab', (event, tabId) => switchToTab(tabId));
ipcMain.on('close-tab', (event, tabId) => closeExternalTab(tabId));
Settings window height — increase from 530 to 600:
settingsWindow = new BrowserWindow({ width: 500, height: 600, ... });
3. src/main/preload.js
Add to the contextBridge.exposeInMainWorld object:
getExternalUrls: () => ipcRenderer.invoke('get-external-urls'),
saveExternalUrls: (urls) => ipcRenderer.invoke('save-external-urls', urls),
switchTab: (tabId) => ipcRenderer.send('switch-tab', tabId),
closeTab: (tabId) => ipcRenderer.send('close-tab', tabId),
onUpdateTabs: (callback) => ipcRenderer.on('update-tabs', (event, data) => callback(data)),
4. src/renderer/settings.html
- Add tab button:
<button class="tab-btn" data-tab="externalurls">External URLs</button> - Add tab panel
#tab-externalurls:- Form row with
nametext input +urlURL input + "Add" button - Unordered list
#external-urls-listwhere each item shows: name, URL, delete button
- Form row with
- JavaScript:
loadExternalUrls()— callswindow.electronAPI.getExternalUrls()and renders the list- "Add" button handler: validates inputs, adds entry with
crypto.randomUUID(), saves - Delete button handler: removes entry by id, saves
- External URLs are saved independently (not via the main form submit); each add/delete auto-saves
5. New file: src/renderer/tabbar.html
Tab bar UI that receives tab data from main process and sends switch/close events back:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';">
<link rel="stylesheet" href="tabbar.css">
</head>
<body>
<div id="tab-container"></div>
<script>
window.electronAPI.onUpdateTabs(({ tabs, activeTabId }) => {
const container = document.getElementById('tab-container');
container.innerHTML = '';
tabs.forEach(tab => {
const el = document.createElement('div');
el.className = 'tab' + (tab.id === activeTabId ? ' active' : '');
el.dataset.id = tab.id;
const label = document.createElement('span');
label.className = 'tab-label';
label.textContent = tab.name;
label.addEventListener('click', () => window.electronAPI.switchTab(tab.id));
el.appendChild(label);
if (tab.closeable) {
const close = document.createElement('button');
close.className = 'tab-close';
close.textContent = '×';
close.addEventListener('click', (e) => { e.stopPropagation(); window.electronAPI.closeTab(tab.id); });
el.appendChild(close);
}
container.appendChild(el);
});
});
</script>
</body>
</html>
6. New file: src/renderer/tabbar.css
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #2c3e50; display: flex; align-items: center; height: 36px; overflow: hidden; -webkit-app-region: no-drag; user-select: none; }
#tab-container { display: flex; height: 100%; gap: 2px; padding: 4px 8px; }
.tab { display: flex; align-items: center; gap: 4px; padding: 0 12px; background: #3d5166; color: #adb5bd; border-radius: 4px; cursor: pointer; max-width: 200px; font-size: 13px; transition: background 0.15s, color 0.15s; }
.tab:hover { background: #4e6a82; color: #fff; }
.tab.active { background: #2196f3; color: #fff; }
.tab-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tab-close { background: none; border: none; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; opacity: 0.7; }
.tab-close:hover { opacity: 1; }
7. src/renderer/settings.css
Add styles for the URL list management UI:
.url-list { list-style: none; margin-top: 0.5rem; }
.url-list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #eee; }
.url-list-item .url-name { font-weight: 500; min-width: 100px; }
.url-list-item .url-href { flex: 1; color: #666; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.url-add-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.url-add-row input[name="newUrlName"] { width: 120px; }
.url-add-row input[name="newUrlHref"] { flex: 1; }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.85rem; }
Verification
- Run
npm start - Open Settings (Ctrl+,) → verify "External URLs" tab appears
- Add a URL entry (name + URL) → verify it appears in the list
- Click Save & Apply (or verify auto-save on add)
- Open the application menu → verify "External URLs" submenu shows the entry
- Click the URL from the menu → verify a tab bar appears at top of main window with "HiveOps" and the new tab
- Click the new tab → verify the external URL loads
- Click "HiveOps" tab → verify switch back to incidentView
- Click × on the external tab → verify tab closes, tab bar disappears if no more external tabs
- Delete URL in settings → verify menu updates after close/reopen