directlx-claude-config/plans/radiant-mapping-goose.md

294 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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