294 lines
10 KiB
Markdown
294 lines
10 KiB
Markdown
# Plan: External URLs Feature
|
||
|
||
## Context
|
||
User wants to manage bookmarked external URLs in HiveOps Browser:
|
||
1. A new "External URLs" tab in the Settings window to add/remove named URL entries
|
||
2. A "External URLs" menu item in the application menu listing those URLs
|
||
3. 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 `tabbarView` BrowserView (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 `BrowserView` when opened, persisted in `externalViews` Map
|
||
- `updateBounds()` is updated to account for the tab bar height
|
||
- The "home" tab always uses the existing `incidentView`
|
||
|
||
## Data Structure (stored via electron-store)
|
||
```json
|
||
"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: []` to `defaultConfig`
|
||
- Add `externalUrls` to `getAll()` 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):**
|
||
```javascript
|
||
let tabbarView = null;
|
||
const externalViews = new Map(); // tabId → BrowserView
|
||
let activeTabId = 'home';
|
||
```
|
||
|
||
**Modify `updateBounds()`:**
|
||
```javascript
|
||
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:
|
||
```javascript
|
||
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:**
|
||
```javascript
|
||
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:**
|
||
```javascript
|
||
function switchToTab(tabId) {
|
||
activeTabId = tabId;
|
||
updateBounds();
|
||
sendTabsToTabbar();
|
||
}
|
||
```
|
||
|
||
**Add `closeExternalTab(tabId)` function:**
|
||
```javascript
|
||
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:**
|
||
```javascript
|
||
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):
|
||
```javascript
|
||
{
|
||
label: 'External URLs',
|
||
submenu: buildExternalUrlsSubmenu()
|
||
}
|
||
```
|
||
|
||
**Add `buildExternalUrlsSubmenu()` function:**
|
||
```javascript
|
||
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:**
|
||
```javascript
|
||
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:
|
||
```javascript
|
||
settingsWindow = new BrowserWindow({ width: 500, height: 600, ... });
|
||
```
|
||
|
||
### 3. `src/main/preload.js`
|
||
Add to the `contextBridge.exposeInMainWorld` object:
|
||
```javascript
|
||
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 `name` text input + `url` URL input + "Add" button
|
||
- Unordered list `#external-urls-list` where each item shows: name, URL, delete button
|
||
- JavaScript:
|
||
- `loadExternalUrls()` — calls `window.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:
|
||
```html
|
||
<!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`
|
||
```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:
|
||
```css
|
||
.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
|
||
1. Run `npm start`
|
||
2. Open Settings (Ctrl+,) → verify "External URLs" tab appears
|
||
3. Add a URL entry (name + URL) → verify it appears in the list
|
||
4. Click Save & Apply (or verify auto-save on add)
|
||
5. Open the application menu → verify "External URLs" submenu shows the entry
|
||
6. Click the URL from the menu → verify a tab bar appears at top of main window with "HiveOps" and the new tab
|
||
7. Click the new tab → verify the external URL loads
|
||
8. Click "HiveOps" tab → verify switch back to incidentView
|
||
9. Click × on the external tab → verify tab closes, tab bar disappears if no more external tabs
|
||
10. Delete URL in settings → verify menu updates after close/reopen
|