From d762366eaff4fbd4efda92baef0d054a3b171dc0 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:39:23 -0500 Subject: [PATCH 01/10] Add notification history and bottom panel tabs design spec --- .../2026-03-24-notification-history-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-24-notification-history-design.md diff --git a/docs/superpowers/specs/2026-03-24-notification-history-design.md b/docs/superpowers/specs/2026-03-24-notification-history-design.md new file mode 100644 index 0000000..4c5e31c --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-notification-history-design.md @@ -0,0 +1,133 @@ +# Notification History & Bottom Panel Tabs Design Spec + +## Goal + +Add a tabbed bottom panel with a built-in Notifications tab that logs all toast notifications from the current session. Plugins can register additional bottom panel tabs alongside it. + +## Scope + +- Bottom panel tab infrastructure (built-in + plugin tabs) +- Built-in "Notifications" tab with in-memory history log +- Plugin bottom panel tabs via the existing SDK panel registration system +- No persistence across sessions — history resets on restart + +## Bottom Panel Tab Infrastructure + +### Tab Bar + +The bottom panel header becomes a tab bar. The first tab is always "Notifications" (built-in, cannot be removed). Plugins that register with `panel_location: Bottom` get additional tabs appended after it. + +``` +[Notifications] [Plugin A] [Plugin B] +───────────────────────────────────── +│ active tab content │ +───────────────────────────────────── +``` + +- Clicking a tab switches the content area +- Active tab is visually highlighted (same `pw-tab-btn` / toggle styling used elsewhere) +- Only one tab's content is visible at a time +- If all plugin tabs are removed (plugins disabled), only "Notifications" remains + +### Plugin Tab Lifecycle + +When a plugin calls `register_panel(Bottom, name, icon)`: +1. The frontend receives a `plugin-widgets-updated` event (existing mechanism) +2. A new tab is created in the bottom panel tab bar with the plugin's name +3. The tab's content area renders the plugin's widget tree (same `renderWidgets()` from `plugin-widgets.js`) + +When a plugin is disabled: +1. Its tab is removed from the tab bar +2. If that tab was active, switch to "Notifications" + +### HTML Structure + +Replace the current bottom panel content: + +```html + +``` + +The tab bar (`#bottom-panel-tabs`) contains tab buttons. The content area (`#bottom-panel-content`) shows the active tab's content. + +### State Persistence + +Add `bottom_panel_visible` to `SavedLayout` and persist it in `state.toml` alongside the existing panel visibility flags. The bottom panel height is already defined in CSS (150px) — persistence of custom height via resize handle is out of scope for now. + +## Notification History Tab + +### In-Memory Log + +`toast.js` maintains an array of notification records: + +```javascript +{ timestamp: Date, level: string, title: string, body: string } +``` + +Every call to `show()` (and the convenience methods) appends to this array before displaying the toast or sending a native notification. The array grows unbounded during a session — in practice, notifications are infrequent enough that memory is not a concern. + +### Rendering + +The Notifications tab content is a scrollable list of entries, newest first. Each entry shows: + +- **Timestamp** — `HH:MM:SS` format, muted color +- **Level icon** — Small colored dot or the same SVG icon used in toasts +- **Title** — Bold, primary text color +- **Body** — Secondary text color, shown on the same line or below if present + +Styling follows the existing panel patterns — `var(--ui-font-small)` for text, theme-aware colors, no hardcoded hex values. + +### Live Updates + +When the bottom panel is open and the Notifications tab is active, new entries appear at the top immediately. The tab content re-renders (or prepends the new entry) when `show()` is called. + +### Clear Button + +A small "Clear" button in the Notifications tab (top-right of the content area or in the tab bar) empties the log array and clears the rendered list. + +### API Addition to `toast.js` + +Export a `getHistory()` function that returns the log array, and an `onNotification(callback)` function for live updates: + +```javascript +exports.toast = { + show, showInApp, dismiss, configure, + info, success, error, warn, + getHistory, // () => Array<{timestamp, level, title, body}> + onNotification, // (callback) => void — called on each new notification + clearHistory, // () => void — empties the log +}; +``` + +## Files Changed + +| Action | Path | Responsibility | +|--------|------|---------------| +| Modify | `crates/conch_tauri/frontend/toast.js` | Add history array, getHistory, onNotification, clearHistory | +| Modify | `crates/conch_tauri/frontend/index.html` | Bottom panel tab bar HTML + CSS, notification history renderer, tab switching logic, bottom panel state persistence | +| Modify | `crates/conch_tauri/frontend/plugin-widgets.js` | Route bottom-panel plugin widgets to bottom panel tabs instead of a separate panel | +| Modify | `crates/conch_tauri/src/lib.rs` | Add `bottom_panel_visible` to SavedLayout | + +## What's NOT Changing + +- The toast display itself (position, styling, auto-dismiss, native notifications) — unchanged +- Plugin SDK (`conch_plugin_sdk`) — no API changes needed, plugins already specify `PanelLocation::Bottom` +- Plugin host (`conch_plugin`) — no changes, panel registration already works +- Config (`conch_core`) — no new config fields + +## Testing + +- Unit test: `SavedLayout` round-trip with `bottom_panel_visible` +- Manual test: toggle bottom panel → Notifications tab visible with empty state +- Manual test: trigger a toast → entry appears in history +- Manual test: clear button empties the list +- Manual test: close and reopen panel → history preserved within session +- Manual test: restart app → history is empty +- Manual test: native notification (when unfocused) also logged in history +- Manual test: enable a bottom-panel plugin → new tab appears +- Manual test: disable that plugin → tab removed, switches to Notifications From 4320dd8e09c000816a1ce2229b93c4ee7d4f9a17 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:41:42 -0500 Subject: [PATCH 02/10] Add notification history implementation plan --- .../plans/2026-03-24-notification-history.md | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-notification-history.md diff --git a/docs/superpowers/plans/2026-03-24-notification-history.md b/docs/superpowers/plans/2026-03-24-notification-history.md new file mode 100644 index 0000000..f4cba5c --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-notification-history.md @@ -0,0 +1,504 @@ +# Notification History & Bottom Panel Tabs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a tabbed bottom panel with a built-in Notifications history tab and support for plugin bottom panel tabs. + +**Architecture:** The bottom panel gets a tab bar with "Notifications" as the permanent first tab. `toast.js` records every notification in an in-memory array. A new `notification-panel.js` module renders the history and manages bottom panel tabs. Plugin widgets registered with `Bottom` location get their own tabs via the existing panel system. + +**Tech Stack:** Frontend JS (IIFE modules), CSS custom properties, existing Tauri event system + +**Spec:** `docs/superpowers/specs/2026-03-24-notification-history-design.md` + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|---------------| +| Modify | `crates/conch_tauri/frontend/toast.js` | Add history array, getHistory, onNotification, clearHistory | +| Create | `crates/conch_tauri/frontend/notification-panel.js` | Bottom panel tab management, notification history renderer | +| Modify | `crates/conch_tauri/frontend/index.html` | Bottom panel HTML restructure, CSS for tabs + history, script include, init call, wire up toggle + layout persistence | +| Modify | `crates/conch_tauri/src/lib.rs` | Add bottom_panel_visible to SavedLayout/WindowLayout | +| Modify | `crates/conch_tauri/frontend/plugin-widgets.js` | Route bottom-panel plugins to notification-panel tab API | + +--- + +### Task 1: Add notification history to toast.js + +**Files:** +- Modify: `crates/conch_tauri/frontend/toast.js` + +- [ ] **Step 1: Add history array and notification callback support** + +At the top of the IIFE (after the existing `let` declarations), add: + +```javascript +const history = []; +let notificationListeners = []; +``` + +- [ ] **Step 2: Record every notification in the history array** + +In the `show()` function, before the native notification check and in-app display, prepend: + +```javascript +const record = { + timestamp: new Date(), + level: opts.level || 'info', + title: opts.title || '', + body: opts.body || '', +}; +history.unshift(record); +for (const cb of notificationListeners) { + try { cb(record); } catch (_) {} +} +``` + +- [ ] **Step 3: Add exported API functions** + +Before the `exports.toast = { ... }` line, add: + +```javascript +function getHistory() { return history; } +function onNotification(cb) { notificationListeners.push(cb); } +function clearHistory() { + history.length = 0; + for (const cb of notificationListeners) { + try { cb(null); } catch (_) {} // null signals "cleared" + } +} +``` + +- [ ] **Step 4: Update the exports** + +```javascript +exports.toast = { show, showInApp, dismiss, configure, info, success, error, warn, getHistory, onNotification, clearHistory }; +``` + +- [ ] **Step 5: Commit** + +```bash +git add crates/conch_tauri/frontend/toast.js +git commit -m "Add notification history tracking to toast system" +``` + +--- + +### Task 2: Restructure bottom panel HTML and CSS for tabs + +**Files:** +- Modify: `crates/conch_tauri/frontend/index.html` + +- [ ] **Step 1: Replace bottom panel HTML** + +Find the current bottom panel markup (around line 1085): + +```html + +``` + +Replace with: + +```html + +``` + +- [ ] **Step 2: Update bottom panel CSS** + +Replace the existing `#bottom-panel-header` CSS (around line 507) with tab-aware styles: + +```css +#bottom-panel-header { + display: flex; align-items: center; flex-shrink: 0; + border-bottom: 1px solid var(--tab-border); + padding: 0; +} +#bottom-panel-tabs { + display: flex; flex: 1; overflow-x: auto; min-width: 0; +} +.bottom-tab { + padding: 5px 14px; cursor: pointer; border: none; background: none; + color: var(--text-muted); font-size: var(--ui-font-small); + border-bottom: 2px solid transparent; white-space: nowrap; +} +.bottom-tab:hover { color: var(--fg); } +.bottom-tab.active { color: var(--fg); border-bottom-color: var(--blue); } +#bottom-panel-actions { + display: flex; align-items: center; gap: 4px; padding: 0 8px; flex-shrink: 0; +} +.bottom-panel-action-btn { + border: none; background: none; color: var(--text-muted); cursor: pointer; + font-size: var(--ui-font-small); padding: 2px 6px; border-radius: 3px; +} +.bottom-panel-action-btn:hover { color: var(--fg); background: var(--active-highlight); } +``` + +Add notification history entry styles: + +```css +.notif-entry { + display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; + font-size: var(--ui-font-small); border-bottom: 1px solid var(--tab-border); +} +.notif-time { color: var(--text-muted); flex-shrink: 0; font-family: "JetBrains Mono", monospace; } +.notif-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; +} +.notif-dot-info { background: var(--cyan); } +.notif-dot-success { background: var(--green); } +.notif-dot-warn { background: var(--yellow); } +.notif-dot-error { background: var(--red); } +.notif-text { flex: 1; min-width: 0; } +.notif-title { color: var(--fg); font-weight: 500; } +.notif-body { color: var(--text-muted); margin-left: 4px; } +.notif-empty { color: var(--text-muted); font-style: italic; padding: 12px 0; } +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/conch_tauri/frontend/index.html +git commit -m "Restructure bottom panel HTML and CSS for tabbed layout" +``` + +--- + +### Task 3: Create notification-panel.js module + +**Files:** +- Create: `crates/conch_tauri/frontend/notification-panel.js` + +- [ ] **Step 1: Create the module** + +```javascript +// Bottom panel with tabbed interface — built-in Notifications tab + plugin tabs. + +(function (exports) { + 'use strict'; + + let tabsEl = null; + let actionsEl = null; + let contentEl = null; + let activeTabId = 'notifications'; + const pluginTabs = new Map(); // tabId -> { name, icon, renderFn } + + function init(opts) { + tabsEl = document.getElementById('bottom-panel-tabs'); + actionsEl = document.getElementById('bottom-panel-actions'); + contentEl = document.getElementById('bottom-panel-content'); + + // Build the permanent Notifications tab. + addTab('notifications', 'Notifications'); + activateTab('notifications'); + + // Add clear button to actions area. + const clearBtn = document.createElement('button'); + clearBtn.className = 'bottom-panel-action-btn'; + clearBtn.textContent = 'Clear'; + clearBtn.title = 'Clear notification history'; + clearBtn.addEventListener('click', () => { + if (window.toast && window.toast.clearHistory) window.toast.clearHistory(); + }); + actionsEl.appendChild(clearBtn); + + // Subscribe to new notifications for live updates. + if (window.toast && window.toast.onNotification) { + window.toast.onNotification((record) => { + if (activeTabId === 'notifications') renderNotifications(); + }); + } + } + + function addTab(id, label) { + const btn = document.createElement('button'); + btn.className = 'bottom-tab'; + btn.textContent = label; + btn.dataset.tabId = id; + btn.addEventListener('click', () => activateTab(id)); + tabsEl.appendChild(btn); + } + + function removeTab(id) { + const btn = tabsEl.querySelector('[data-tab-id="' + id + '"]'); + if (btn) btn.remove(); + pluginTabs.delete(id); + if (activeTabId === id) activateTab('notifications'); + } + + function activateTab(id) { + activeTabId = id; + // Update tab button states. + for (const btn of tabsEl.querySelectorAll('.bottom-tab')) { + btn.classList.toggle('active', btn.dataset.tabId === id); + } + // Show/hide clear button (only for notifications tab). + if (actionsEl) { + actionsEl.style.display = id === 'notifications' ? '' : 'none'; + } + // Render content. + if (id === 'notifications') { + renderNotifications(); + } else { + const plugin = pluginTabs.get(id); + if (plugin && plugin.renderFn) { + contentEl.innerHTML = ''; + plugin.renderFn(contentEl); + } + } + } + + function renderNotifications() { + if (!contentEl) return; + contentEl.innerHTML = ''; + + const history = (window.toast && window.toast.getHistory) ? window.toast.getHistory() : []; + if (history.length === 0) { + const empty = document.createElement('div'); + empty.className = 'notif-empty'; + empty.textContent = 'No notifications yet.'; + contentEl.appendChild(empty); + return; + } + + const frag = document.createDocumentFragment(); + for (const entry of history) { + const row = document.createElement('div'); + row.className = 'notif-entry'; + + const time = document.createElement('span'); + time.className = 'notif-time'; + const d = entry.timestamp; + time.textContent = String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0'); + row.appendChild(time); + + const dot = document.createElement('span'); + dot.className = 'notif-dot notif-dot-' + (entry.level || 'info'); + row.appendChild(dot); + + const text = document.createElement('span'); + text.className = 'notif-text'; + const title = document.createElement('span'); + title.className = 'notif-title'; + title.textContent = entry.title || ''; + text.appendChild(title); + if (entry.body) { + const body = document.createElement('span'); + body.className = 'notif-body'; + body.textContent = entry.body; + text.appendChild(body); + } + row.appendChild(text); + + frag.appendChild(row); + } + contentEl.appendChild(frag); + } + + // --- Plugin tab API --- + + function addPluginTab(id, name, renderFn) { + if (pluginTabs.has(id)) return; + pluginTabs.set(id, { name, renderFn }); + addTab(id, name); + } + + function removePluginTab(id) { + removeTab(id); + } + + function updatePluginTab(id, renderFn) { + const plugin = pluginTabs.get(id); + if (plugin) { + plugin.renderFn = renderFn; + if (activeTabId === id) activateTab(id); + } + } + + exports.notificationPanel = { + init, + activateTab, + addPluginTab, + removePluginTab, + updatePluginTab, + }; +})(window); +``` + +- [ ] **Step 2: Add script tag in index.html** + +After the `toast.js` script tag and before `ssh-panel.js`, add: + +```html + +``` + +- [ ] **Step 3: Initialize the panel in the startup code** + +In the `start()` async function in `index.html`, after the toast configuration block and before the tab/terminal setup, add: + +```javascript +// Initialize bottom panel tabs. +if (window.notificationPanel) { + window.notificationPanel.init(); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/conch_tauri/frontend/notification-panel.js crates/conch_tauri/frontend/index.html +git commit -m "Add notification panel module with tabbed bottom panel and history renderer" +``` + +--- + +### Task 4: Wire up bottom panel toggle with state persistence + +**Files:** +- Modify: `crates/conch_tauri/src/lib.rs` +- Modify: `crates/conch_tauri/frontend/index.html` + +- [ ] **Step 1: Add bottom_panel_visible to SavedLayout and WindowLayout** + +In `lib.rs`, add to `SavedLayout` struct (around line 509): + +```rust +bottom_panel_visible: bool, +``` + +Add to `get_saved_layout()` (around line 524): + +```rust +bottom_panel_visible: state.layout.bottom_panel_visible, +``` + +Add to `WindowLayout` struct (around line 500): + +```rust +bottom_panel_visible: Option, +``` + +Add to `save_window_layout()` (around line 537), alongside the other panel persistence: + +```rust +if let Some(v) = layout.bottom_panel_visible { + state.layout.bottom_panel_visible = v; +} +``` + +- [ ] **Step 2: Restore bottom panel visibility on startup** + +In `index.html`, find where `get_saved_layout` is called and panel visibility is restored (search for `files_panel_visible` or `ssh_panel_visible`). Add alongside the existing panel restoration: + +```javascript +if (layoutData.bottom_panel_visible === false) { + document.getElementById('bottom-panel').classList.add('hidden'); +} else { + document.getElementById('bottom-panel').classList.remove('hidden'); +} +``` + +- [ ] **Step 3: Update the toggle handler to persist state** + +Replace the existing `toggle-bottom-panel` handler (around line 2066): + +```javascript +if (action === 'toggle-bottom-panel') { + const bp = document.getElementById('bottom-panel'); + if (bp) { + bp.classList.toggle('hidden'); + setTimeout(() => fitAndResizeTab(currentTab()), 50); + debouncedSaveLayout(); + } + return; +} +``` + +The `debouncedSaveLayout()` function (or `saveLayoutState()`) already exists for the other panels. Make sure the bottom panel state is included in the layout data it sends. Find where `save_window_layout` is called with `ssh_panel_visible` and add: + +```javascript +bottom_panel_visible: !document.getElementById('bottom-panel').classList.contains('hidden'), +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo check -p conch_tauri` +Expected: Compiles + +- [ ] **Step 5: Commit** + +```bash +git add crates/conch_tauri/src/lib.rs crates/conch_tauri/frontend/index.html +git commit -m "Wire up bottom panel toggle with layout state persistence" +``` + +--- + +### Task 5: Route plugin bottom panels to bottom panel tabs + +**Files:** +- Modify: `crates/conch_tauri/frontend/plugin-widgets.js` + +- [ ] **Step 1: Detect bottom panel plugins and route to tab API** + +In `plugin-widgets.js`, find where plugin panel widgets are updated (the `plugin-widgets-updated` event listener). When a panel's location is `"bottom"`, instead of rendering into a separate container, route it through the notification panel tab API: + +```javascript +// Inside the plugin-widgets-updated handler, after getting panel info: +if (panel.location === 'bottom' && window.notificationPanel) { + window.notificationPanel.addPluginTab( + 'plugin-' + panel.plugin_name, + panel.name || panel.plugin_name, + (container) => { + renderWidgets(container, panel.widgets_json, panel.plugin_name); + } + ); + return; // Don't render in the default panel location +} +``` + +When a plugin is disabled, remove its tab: + +```javascript +// In the plugin disable/unload handler: +if (window.notificationPanel) { + window.notificationPanel.removePluginTab('plugin-' + pluginName); +} +``` + +This is the lightest-touch integration — the existing widget renderer is reused, just targeted at the bottom panel tab content area instead of a sidebar panel. + +- [ ] **Step 2: Commit** + +```bash +git add crates/conch_tauri/frontend/plugin-widgets.js +git commit -m "Route bottom-panel plugins to bottom panel tab system" +``` + +--- + +## Verification + +After all tasks: + +- [ ] `cargo check -p conch_tauri` — compiles +- [ ] `cargo test --workspace` — all tests pass +- [ ] Manual: toggle bottom panel → Notifications tab visible, empty state message shown +- [ ] Manual: trigger actions that produce toasts → entries appear in history with timestamp, level dot, title, body +- [ ] Manual: click Clear → history empties +- [ ] Manual: close panel, trigger more toasts, reopen → all entries present +- [ ] Manual: restart app → history is empty +- [ ] Manual: native notification (unfocused) → also logged in history +- [ ] Manual: toggle bottom panel → state persists across panel switches and sessions From c6ab5b26d6bace3d1f5a13e96f0af1d46f90a229 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:43:01 -0500 Subject: [PATCH 03/10] Add notification history tracking to toast system --- crates/conch_tauri/frontend/toast.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/conch_tauri/frontend/toast.js b/crates/conch_tauri/frontend/toast.js index 6a33ba0..996ed89 100644 --- a/crates/conch_tauri/frontend/toast.js +++ b/crates/conch_tauri/frontend/toast.js @@ -8,6 +8,8 @@ let toastContainer = null; let position = 'bottom'; // 'top' or 'bottom' let nativeNotificationsEnabled = true; + const history = []; + let notificationListeners = []; function ensureContainer() { if (toastContainer) return; @@ -60,6 +62,17 @@ * @returns {HTMLElement|null} the toast element (null if sent as native notification) */ function show(opts) { + const record = { + timestamp: new Date(), + level: opts.level || 'info', + title: opts.title || '', + body: opts.body || '', + }; + history.unshift(record); + for (const cb of notificationListeners) { + try { cb(record); } catch (_) {} + } + // Try native notification if window is not focused if (!opts.forceInApp && nativeNotificationsEnabled && !document.hasFocus()) { if (sendNativeNotification(opts.title, opts.body)) { @@ -152,5 +165,14 @@ const esc = window.utils.esc; - exports.toast = { show, showInApp, dismiss, configure, info, success, error, warn }; + function getHistory() { return history; } + function onNotification(cb) { notificationListeners.push(cb); } + function clearHistory() { + history.length = 0; + for (const cb of notificationListeners) { + try { cb(null); } catch (_) {} + } + } + + exports.toast = { show, showInApp, dismiss, configure, info, success, error, warn, getHistory, onNotification, clearHistory }; })(window); From d95fc64dc05a85b586a276ac1b2bf691cdd3567b Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:44:25 -0500 Subject: [PATCH 04/10] Restructure bottom panel HTML and CSS for tabbed layout --- crates/conch_tauri/frontend/index.html | 47 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/conch_tauri/frontend/index.html b/crates/conch_tauri/frontend/index.html index 39f9d96..27fd0cf 100644 --- a/crates/conch_tauri/frontend/index.html +++ b/crates/conch_tauri/frontend/index.html @@ -505,11 +505,43 @@ } #bottom-panel.hidden { display: none; } #bottom-panel-header { - display: flex; align-items: center; padding: 4px 10px; - font-size: var(--ui-font-small); color: var(--dim-fg); text-transform: uppercase; - letter-spacing: 0.5px; flex-shrink: 0; - border-bottom: 1px solid var(--tab-border); - } + display: flex; align-items: center; flex-shrink: 0; + border-bottom: 1px solid var(--tab-border); padding: 0; + } + #bottom-panel-tabs { + display: flex; flex: 1; overflow-x: auto; min-width: 0; + } + .bottom-tab { + padding: 5px 14px; cursor: pointer; border: none; background: none; + color: var(--text-muted); font-size: var(--ui-font-small); + border-bottom: 2px solid transparent; white-space: nowrap; + } + .bottom-tab:hover { color: var(--fg); } + .bottom-tab.active { color: var(--fg); border-bottom-color: var(--blue); } + #bottom-panel-actions { + display: flex; align-items: center; gap: 4px; padding: 0 8px; flex-shrink: 0; + } + .bottom-panel-action-btn { + border: none; background: none; color: var(--text-muted); cursor: pointer; + font-size: var(--ui-font-small); padding: 2px 6px; border-radius: 3px; + } + .bottom-panel-action-btn:hover { color: var(--fg); background: var(--active-highlight); } + .notif-entry { + display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; + font-size: var(--ui-font-small); border-bottom: 1px solid var(--tab-border); + } + .notif-time { color: var(--text-muted); flex-shrink: 0; font-family: "JetBrains Mono", monospace; } + .notif-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; + } + .notif-dot-info { background: var(--cyan); } + .notif-dot-success { background: var(--green); } + .notif-dot-warn { background: var(--yellow); } + .notif-dot-error { background: var(--red); } + .notif-text { flex: 1; min-width: 0; } + .notif-title { color: var(--fg); font-weight: 500; } + .notif-body { color: var(--text-muted); margin-left: 4px; } + .notif-empty { color: var(--text-muted); font-style: italic; padding: 12px 0; } #bottom-panel-content { flex: 1; overflow-y: auto; padding: 4px 8px; } #ssh-panel-wrap { display: flex; @@ -1083,7 +1115,10 @@ From e55b41775b82ba928a5260913075b730325efb09 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:45:06 -0500 Subject: [PATCH 05/10] Add tabbed bottom panel with notification history renderer --- crates/conch_tauri/frontend/index.html | 4 + .../frontend/notification-panel.js | 145 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 crates/conch_tauri/frontend/notification-panel.js diff --git a/crates/conch_tauri/frontend/index.html b/crates/conch_tauri/frontend/index.html index 27fd0cf..2d022c2 100644 --- a/crates/conch_tauri/frontend/index.html +++ b/crates/conch_tauri/frontend/index.html @@ -1137,6 +1137,7 @@ + @@ -1285,6 +1286,9 @@ nativeNotifications: appCfg.native_notifications !== false, }); } + if (window.notificationPanel) { + window.notificationPanel.init(); + } // Apply UI chrome font settings. const r = document.documentElement.style; diff --git a/crates/conch_tauri/frontend/notification-panel.js b/crates/conch_tauri/frontend/notification-panel.js new file mode 100644 index 0000000..599d55d --- /dev/null +++ b/crates/conch_tauri/frontend/notification-panel.js @@ -0,0 +1,145 @@ +// Bottom panel with tabbed interface — built-in Notifications tab + plugin tabs. + +(function (exports) { + 'use strict'; + + let tabsEl = null; + let actionsEl = null; + let contentEl = null; + let activeTabId = 'notifications'; + const pluginTabs = new Map(); + + function init() { + tabsEl = document.getElementById('bottom-panel-tabs'); + actionsEl = document.getElementById('bottom-panel-actions'); + contentEl = document.getElementById('bottom-panel-content'); + + addTab('notifications', 'Notifications'); + activateTab('notifications'); + + const clearBtn = document.createElement('button'); + clearBtn.className = 'bottom-panel-action-btn'; + clearBtn.textContent = 'Clear'; + clearBtn.title = 'Clear notification history'; + clearBtn.addEventListener('click', () => { + if (window.toast && window.toast.clearHistory) window.toast.clearHistory(); + }); + actionsEl.appendChild(clearBtn); + + if (window.toast && window.toast.onNotification) { + window.toast.onNotification((record) => { + if (activeTabId === 'notifications') renderNotifications(); + }); + } + } + + function addTab(id, label) { + const btn = document.createElement('button'); + btn.className = 'bottom-tab'; + btn.textContent = label; + btn.dataset.tabId = id; + btn.addEventListener('click', () => activateTab(id)); + tabsEl.appendChild(btn); + } + + function removeTab(id) { + const btn = tabsEl.querySelector('[data-tab-id="' + id + '"]'); + if (btn) btn.remove(); + pluginTabs.delete(id); + if (activeTabId === id) activateTab('notifications'); + } + + function activateTab(id) { + activeTabId = id; + for (const btn of tabsEl.querySelectorAll('.bottom-tab')) { + btn.classList.toggle('active', btn.dataset.tabId === id); + } + if (actionsEl) { + actionsEl.style.display = id === 'notifications' ? '' : 'none'; + } + if (id === 'notifications') { + renderNotifications(); + } else { + const plugin = pluginTabs.get(id); + if (plugin && plugin.renderFn) { + contentEl.innerHTML = ''; + plugin.renderFn(contentEl); + } + } + } + + function renderNotifications() { + if (!contentEl) return; + contentEl.innerHTML = ''; + + const history = (window.toast && window.toast.getHistory) ? window.toast.getHistory() : []; + if (history.length === 0) { + const empty = document.createElement('div'); + empty.className = 'notif-empty'; + empty.textContent = 'No notifications yet.'; + contentEl.appendChild(empty); + return; + } + + const frag = document.createDocumentFragment(); + for (const entry of history) { + const row = document.createElement('div'); + row.className = 'notif-entry'; + + const time = document.createElement('span'); + time.className = 'notif-time'; + const d = entry.timestamp; + time.textContent = String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0'); + row.appendChild(time); + + const dot = document.createElement('span'); + dot.className = 'notif-dot notif-dot-' + (entry.level || 'info'); + row.appendChild(dot); + + const text = document.createElement('span'); + text.className = 'notif-text'; + const title = document.createElement('span'); + title.className = 'notif-title'; + title.textContent = entry.title || ''; + text.appendChild(title); + if (entry.body) { + const body = document.createElement('span'); + body.className = 'notif-body'; + body.textContent = entry.body; + text.appendChild(body); + } + row.appendChild(text); + + frag.appendChild(row); + } + contentEl.appendChild(frag); + } + + function addPluginTab(id, name, renderFn) { + if (pluginTabs.has(id)) return; + pluginTabs.set(id, { name, renderFn }); + addTab(id, name); + } + + function removePluginTab(id) { + removeTab(id); + } + + function updatePluginTab(id, renderFn) { + const plugin = pluginTabs.get(id); + if (plugin) { + plugin.renderFn = renderFn; + if (activeTabId === id) activateTab(id); + } + } + + exports.notificationPanel = { + init, + activateTab, + addPluginTab, + removePluginTab, + updatePluginTab, + }; +})(window); From c085a5d0dc054dad5cbed82346355ad1704ef27d Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:48:18 -0500 Subject: [PATCH 06/10] Wire up bottom panel toggle with layout state persistence --- crates/conch_tauri/frontend/index.html | 23 ++++++++++++++++++----- crates/conch_tauri/src/lib.rs | 6 ++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/conch_tauri/frontend/index.html b/crates/conch_tauri/frontend/index.html index 2d022c2..cead74e 100644 --- a/crates/conch_tauri/frontend/index.html +++ b/crates/conch_tauri/frontend/index.html @@ -1289,6 +1289,14 @@ if (window.notificationPanel) { window.notificationPanel.init(); } + try { + const layoutData = await invoke('get_saved_layout'); + if (layoutData.bottom_panel_visible === false) { + document.getElementById('bottom-panel').classList.add('hidden'); + } else { + document.getElementById('bottom-panel').classList.remove('hidden'); + } + } catch (_) {} // Apply UI chrome font settings. const r = document.documentElement.style; @@ -1826,7 +1834,7 @@ // Save window layout on resize (debounced). let windowResaveSaveTimer = null; - window.addEventListener('resize', () => { + function debouncedSaveLayout() { if (windowResaveSaveTimer) clearTimeout(windowResaveSaveTimer); windowResaveSaveTimer = setTimeout(() => { invoke('save_window_layout', { @@ -1835,10 +1843,12 @@ ssh_panel_visible: window.sshPanel ? !window.sshPanel.isHidden() : true, files_panel_width: filesPanelEl.offsetWidth, files_panel_visible: window.filesPanel ? !window.filesPanel.isHidden() : true, + bottom_panel_visible: !document.getElementById('bottom-panel').classList.contains('hidden'), }, }).catch(() => {}); }, 500); - }); + } + window.addEventListener('resize', debouncedSaveLayout); // ---- Vault init ---- if (window.vault) { @@ -2101,11 +2111,14 @@ invoke('set_zoom_level', { scaleFactor: 1.0 }).catch(() => {}); return; } - // Toggle bottom panel (placeholder — no bottom panel content yet). + // Toggle bottom panel. if (action === 'toggle-bottom-panel') { const bp = document.getElementById('bottom-panel'); - if (bp) bp.classList.toggle('hidden'); - setTimeout(() => fitAndResizeTab(currentTab()), 50); + if (bp) { + bp.classList.toggle('hidden'); + setTimeout(() => fitAndResizeTab(currentTab()), 50); + debouncedSaveLayout(); + } return; } if (action === 'about') { diff --git a/crates/conch_tauri/src/lib.rs b/crates/conch_tauri/src/lib.rs index d140574..3eab3ec 100644 --- a/crates/conch_tauri/src/lib.rs +++ b/crates/conch_tauri/src/lib.rs @@ -502,6 +502,7 @@ struct WindowLayout { ssh_panel_visible: Option, files_panel_width: Option, files_panel_visible: Option, + bottom_panel_visible: Option, } /// Layout state sent to the frontend on load. @@ -513,6 +514,7 @@ struct SavedLayout { ssh_panel_visible: bool, files_panel_width: f64, files_panel_visible: bool, + bottom_panel_visible: bool, } #[tauri::command] @@ -530,6 +532,7 @@ fn get_saved_layout() -> SavedLayout { ssh_panel_visible: state.layout.right_panel_visible, files_panel_width: state.layout.left_panel_width as f64, files_panel_visible: state.layout.left_panel_visible, + bottom_panel_visible: state.layout.bottom_panel_visible, } } @@ -555,6 +558,9 @@ fn save_window_layout(window: tauri::WebviewWindow, layout: WindowLayout) { if let Some(v) = layout.files_panel_visible { state.layout.left_panel_visible = v; } + if let Some(v) = layout.bottom_panel_visible { + state.layout.bottom_panel_visible = v; + } let _ = config::save_persistent_state(&state); } From b142938888fa64b8aeb2d8dc47aaabd49d1262e3 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Tue, 24 Mar 2026 20:50:24 -0500 Subject: [PATCH 07/10] Route bottom-panel plugins to bottom panel tab system --- crates/conch_tauri/frontend/plugin-widgets.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/conch_tauri/frontend/plugin-widgets.js b/crates/conch_tauri/frontend/plugin-widgets.js index a635d7e..9bf4183 100644 --- a/crates/conch_tauri/frontend/plugin-widgets.js +++ b/crates/conch_tauri/frontend/plugin-widgets.js @@ -7,6 +7,9 @@ let invoke = null; let listen = null; const pluginMenuItems = []; + // Tracks handles for panels registered at the bottom location. + // Maps handle (number) → plugin name (string). + const bottomPanelHandles = new Map(); function log(msg) { console.log('[plugin-widgets] ' + msg); } @@ -14,9 +17,49 @@ invoke = opts.invoke; listen = opts.listen; + // Track panel registrations so we know which handles belong to bottom panels. + listen('plugin-panel-registered', (event) => { + const { handle, plugin, name, location } = event.payload; + if (location === 'bottom') { + bottomPanelHandles.set(handle, plugin); + if (window.notificationPanel) { + window.notificationPanel.addPluginTab( + 'plugin-' + plugin, + name || plugin, + (container) => { + renderWidgets(container, '[]', plugin); + } + ); + } + } + }); + + // Listen for plugin panel removal and clean up bottom panel tabs. + listen('plugin-panel-removed', (event) => { + const { handle, plugin } = event.payload; + if (bottomPanelHandles.has(handle)) { + bottomPanelHandles.delete(handle); + if (window.notificationPanel) { + window.notificationPanel.removePluginTab('plugin-' + plugin); + } + } + }); + // Listen for widget updates from plugins. listen('plugin-widgets-updated', (event) => { const { handle, plugin, widgets_json } = event.payload; + if (bottomPanelHandles.has(handle)) { + // Route bottom-panel plugin widgets to the notification panel tab system. + if (window.notificationPanel) { + window.notificationPanel.updatePluginTab( + 'plugin-' + plugin, + (container) => { + renderWidgets(container, widgets_json, plugin); + } + ); + } + return; + } const container = document.querySelector(`[data-plugin-handle="${handle}"]`); if (container) { renderWidgets(container, widgets_json, plugin); From 0062c8e8e8e024d6b3a79591ad968a0528142ca5 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Sun, 29 Mar 2026 12:02:05 -0500 Subject: [PATCH 08/10] Use Core Text for instant font enumeration on macOS Replace font-kit glyph-advance comparison (loaded every font from disk) with Core Text symbolic trait check (reads cached metadata). font-kit remains as fallback on non-macOS. Also make list_system_fonts async via spawn_blocking, hot-reload terminal font on config change, and defer fitAddon.fit() to requestAnimationFrame after font change. --- crates/conch_tauri/Cargo.toml | 2 + crates/conch_tauri/frontend/index.html | 25 +++++ crates/conch_tauri/src/fonts.rs | 124 ++++++++++++++++++------- crates/conch_tauri/src/settings.rs | 25 ++--- 4 files changed, 126 insertions(+), 50 deletions(-) diff --git a/crates/conch_tauri/Cargo.toml b/crates/conch_tauri/Cargo.toml index ec1f612..b697e06 100644 --- a/crates/conch_tauri/Cargo.toml +++ b/crates/conch_tauri/Cargo.toml @@ -48,6 +48,8 @@ libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSString", "NSLocale"] } +core-text = "20" +core-foundation = "0.9" [dev-dependencies] tempfile = "3" diff --git a/crates/conch_tauri/frontend/index.html b/crates/conch_tauri/frontend/index.html index 1b258b0..afb0a71 100644 --- a/crates/conch_tauri/frontend/index.html +++ b/crates/conch_tauri/frontend/index.html @@ -2919,6 +2919,31 @@ document.body.style.fontSize = appCfg.ui_font_size + 'px'; } + // Re-apply terminal font/size to all open panes. + try { + const termCfg = await invoke('get_terminal_config'); + let newTermFont = '"JetBrains Mono", "Fira Code", "Cascadia Code"' + FONT_FALLBACKS; + if (termCfg.font_family) { + newTermFont = '"' + termCfg.font_family + '", "Fira Code", "Cascadia Code"' + FONT_FALLBACKS; + } + termFontFamily = newTermFont; + const newTermSize = termCfg.font_size > 0 ? termCfg.font_size : 14; + termFontSize = newTermSize; + for (const pane of panes.values()) { + pane.term.options.fontFamily = newTermFont; + pane.term.options.fontSize = newTermSize; + } + // Allow the browser to load/measure the new font before re-fitting. + requestAnimationFrame(() => { + for (const pane of panes.values()) { + if (pane.fitAddon) pane.fitAddon.fit(); + pane.term.refresh(0, pane.term.rows - 1); + } + }); + } catch (e) { + console.warn('Failed to reload terminal font:', e); + } + // Re-apply notification settings. if (window.toast && window.toast.configure) { window.toast.configure({ diff --git a/crates/conch_tauri/src/fonts.rs b/crates/conch_tauri/src/fonts.rs index 6586e9d..c7f3df5 100644 --- a/crates/conch_tauri/src/fonts.rs +++ b/crates/conch_tauri/src/fonts.rs @@ -1,6 +1,8 @@ //! System font enumeration for the settings UI. +//! +//! On macOS, uses Core Text symbolic traits (cached metadata — no font loading). +//! On other platforms, falls back to font-kit glyph advance comparison. -use font_kit::source::SystemSource; use serde::Serialize; use ts_rs::TS; @@ -11,10 +13,85 @@ pub(crate) struct SystemFonts { monospace: Vec, } -/// Check whether the first available font in a family is monospace by comparing -/// the advance widths of 'i' (narrow) and 'M' (wide). If they match the font -/// is fixed-pitch. -fn is_monospace(source: &SystemSource, family: &str) -> bool { +/// Shared post-processing: sort, dedup, inject current terminal font. +fn finalize( + mut all: Vec, + mut monospace: Vec, + current_terminal_font: Option<&str>, +) -> SystemFonts { + all.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + all.dedup(); + monospace.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + monospace.dedup(); + + if let Some(current) = current_terminal_font { + if !current.is_empty() && !monospace.iter().any(|f| f == current) { + monospace.push(current.to_string()); + monospace.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + } + } + + SystemFonts { all, monospace } +} + +// --------------------------------------------------------------------------- +// macOS: Core Text symbolic traits (fast — reads cached metadata, no disk I/O) +// --------------------------------------------------------------------------- + +#[cfg(target_os = "macos")] +pub(crate) fn enumerate_system_fonts(current_terminal_font: Option<&str>) -> SystemFonts { + use core_text::font_collection; + use core_text::font_descriptor::{SymbolicTraitAccessors, TraitAccessors}; + use std::collections::HashSet; + + let collection = font_collection::create_for_all_families(); + let descriptors = match collection.get_descriptors() { + Some(d) => d, + None => return finalize(Vec::new(), Vec::new(), current_terminal_font), + }; + + let mut all_set = HashSet::new(); + let mut mono_set = HashSet::new(); + + for i in 0..descriptors.len() { + let desc = descriptors.get(i).unwrap(); + let family = desc.family_name(); + all_set.insert(family.clone()); + + let symbolic = desc.traits().symbolic_traits(); + if symbolic.is_monospace() { + mono_set.insert(family); + } + } + + let all: Vec = all_set.into_iter().collect(); + let monospace: Vec = mono_set.into_iter().collect(); + + finalize(all, monospace, current_terminal_font) +} + +// --------------------------------------------------------------------------- +// Non-macOS: font-kit glyph advance comparison (slower but cross-platform) +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "macos"))] +pub(crate) fn enumerate_system_fonts(current_terminal_font: Option<&str>) -> SystemFonts { + use font_kit::source::SystemSource; + + let source = SystemSource::new(); + let all = source.all_families().unwrap_or_default(); + + let monospace: Vec = all + .iter() + .filter(|f| is_monospace_fontkit(&source, f)) + .cloned() + .collect(); + + finalize(all, monospace, current_terminal_font) +} + +#[cfg(not(target_os = "macos"))] +fn is_monospace_fontkit(source: &font_kit::source::SystemSource, family: &str) -> bool { let handle = match source.select_family_by_name(family) { Ok(h) => h, Err(_) => return false, @@ -42,40 +119,22 @@ fn is_monospace(source: &SystemSource, family: &str) -> bool { } } -/// Enumerate system fonts. Returns all families (sorted) and the monospace -/// subset. `current_terminal_font` is always included in the monospace list -/// even if detection misses it. -pub(crate) fn enumerate_system_fonts(current_terminal_font: Option<&str>) -> SystemFonts { - let source = SystemSource::new(); - let mut all = source.all_families().unwrap_or_default(); - all.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - all.dedup(); - - let mut monospace: Vec = all - .iter() - .filter(|f| is_monospace(&source, f)) - .cloned() - .collect(); - - // Ensure the user's current terminal font is always present. - if let Some(current) = current_terminal_font { - if !current.is_empty() && !monospace.iter().any(|f| f == current) { - monospace.push(current.to_string()); - monospace.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - } - } - - SystemFonts { all, monospace } -} +// --------------------------------------------------------------------------- +// Tauri command +// --------------------------------------------------------------------------- #[tauri::command] -pub(crate) fn list_system_fonts(state: tauri::State<'_, crate::TauriState>) -> SystemFonts { +pub(crate) async fn list_system_fonts( + state: tauri::State<'_, crate::TauriState>, +) -> Result { let current = { let cfg = state.config.read(); let family = cfg.resolved_terminal_font().normal.family.clone(); if family.is_empty() { None } else { Some(family) } }; - enumerate_system_fonts(current.as_deref()) + tokio::task::spawn_blocking(move || enumerate_system_fonts(current.as_deref())) + .await + .map_err(|e| format!("Font enumeration failed: {e}")) } #[cfg(test)] @@ -151,7 +210,6 @@ mod tests { #[test] fn known_monospace_font_detected() { - // Menlo is available on all macOS systems, Courier New on all platforms. let fonts = enumerate_system_fonts(None); let has_known_mono = fonts.monospace.iter().any(|f| { f == "Menlo" || f == "Courier New" || f == "Consolas" || f == "DejaVu Sans Mono" diff --git a/crates/conch_tauri/src/settings.rs b/crates/conch_tauri/src/settings.rs index ddb84a1..a997c91 100644 --- a/crates/conch_tauri/src/settings.rs +++ b/crates/conch_tauri/src/settings.rs @@ -85,21 +85,9 @@ pub(crate) fn needs_restart(old: &UserConfig, new: &UserConfig) -> bool { return true; } - // Terminal font - let old_font = old.resolved_terminal_font(); - let new_font = new.resolved_terminal_font(); - if old_font.normal.family != new_font.normal.family { - return true; - } - if old_font.size != new_font.size { - return true; - } - if old_font.offset.x != new_font.offset.x { - return true; - } - if old_font.offset.y != new_font.offset.y { - return true; - } + // Terminal font — hot-reloaded via config-changed event, no restart needed. + + // Scroll sensitivity if old.terminal.scroll_sensitivity != new.terminal.scroll_sensitivity { return true; } @@ -170,11 +158,14 @@ mod tests { } #[test] - fn changed_terminal_font_needs_restart() { + fn changed_terminal_font_no_restart() { let a = UserConfig::default(); let mut b = UserConfig::default(); b.terminal.font.size = 18.0; - assert!(needs_restart(&a, &b)); + assert!( + !needs_restart(&a, &b), + "Terminal font is hot-reloadable, should not require restart" + ); } #[test] From 704845d321c7d81972e6c0cf415d80cdea637288 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Sun, 29 Mar 2026 12:05:56 -0500 Subject: [PATCH 09/10] Fix bottom-panel plugin routing and stale tab cleanup Skip creating sidebar containers for bottom-panel plugins in index.html since they are routed to the tabbed bottom panel via plugin-widgets.js. Fix event name mismatch: frontend listened for 'plugin-panel-removed' (singular) but backend emits 'plugin-panels-removed' (plural) with { plugin, handles }. Also add sidebar container cleanup handler for the same event so disabling a plugin removes its UI elements. --- crates/conch_tauri/frontend/index.html | 24 +++++++++++++++++-- crates/conch_tauri/frontend/plugin-widgets.js | 16 +++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/conch_tauri/frontend/index.html b/crates/conch_tauri/frontend/index.html index afb0a71..6bee763 100644 --- a/crates/conch_tauri/frontend/index.html +++ b/crates/conch_tauri/frontend/index.html @@ -2257,8 +2257,10 @@ // Listen for plugin panel registrations and create panel containers. listenOnCurrentWindow('plugin-panel-registered', async (event) => { const { handle, plugin, name, location } = event.payload; - // For now, add plugin panels as additional content in the right sidebar. - // Future: support left/bottom panel locations with tabbed UI. + // Bottom-panel plugins are routed to the tabbed bottom panel via + // plugin-widgets.js — skip creating a sidebar container for them. + if (location === 'bottom') return; + // Add non-bottom plugin panels as additional content in the right sidebar. const sshPanel = document.getElementById('ssh-panel'); if (!sshPanel) return; @@ -2291,6 +2293,24 @@ console.error('Initial plugin render failed:', e); } }); + + // Clean up sidebar containers when a plugin is disabled/unloaded. + listenOnCurrentWindow('plugin-panels-removed', (event) => { + const { handles } = event.payload; + for (const handle of handles) { + const container = document.querySelector(`[data-plugin-handle="${handle}"]`); + if (container) { + // Remove the separator and header preceding the container. + const prev = container.previousElementSibling; + if (prev && prev.classList.contains('ssh-section-header')) { + const sep = prev.previousElementSibling; + if (sep && sep.classList.contains('ssh-panel-separator')) sep.remove(); + prev.remove(); + } + container.remove(); + } + } + }); } // ---- Drag and drop ---- diff --git a/crates/conch_tauri/frontend/plugin-widgets.js b/crates/conch_tauri/frontend/plugin-widgets.js index 9bf4183..1f43f21 100644 --- a/crates/conch_tauri/frontend/plugin-widgets.js +++ b/crates/conch_tauri/frontend/plugin-widgets.js @@ -34,15 +34,17 @@ } }); - // Listen for plugin panel removal and clean up bottom panel tabs. - listen('plugin-panel-removed', (event) => { - const { handle, plugin } = event.payload; - if (bottomPanelHandles.has(handle)) { - bottomPanelHandles.delete(handle); - if (window.notificationPanel) { - window.notificationPanel.removePluginTab('plugin-' + plugin); + // Listen for plugin panel removal (batch event) and clean up bottom panel tabs. + listen('plugin-panels-removed', (event) => { + const { plugin, handles } = event.payload; + for (const handle of handles) { + if (bottomPanelHandles.has(handle)) { + bottomPanelHandles.delete(handle); } } + if (window.notificationPanel) { + window.notificationPanel.removePluginTab('plugin-' + plugin); + } }); // Listen for widget updates from plugins. From 72cb1f896fda8f5394ddee3490c62d43080c6e50 Mon Sep 17 00:00:00 2001 From: an0nn30 Date: Sun, 29 Mar 2026 12:07:32 -0500 Subject: [PATCH 10/10] Fix rustfmt formatting in fonts.rs --- crates/conch_tauri/src/fonts.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/conch_tauri/src/fonts.rs b/crates/conch_tauri/src/fonts.rs index c7f3df5..ccb2aa1 100644 --- a/crates/conch_tauri/src/fonts.rs +++ b/crates/conch_tauri/src/fonts.rs @@ -130,7 +130,11 @@ pub(crate) async fn list_system_fonts( let current = { let cfg = state.config.read(); let family = cfg.resolved_terminal_font().normal.family.clone(); - if family.is_empty() { None } else { Some(family) } + if family.is_empty() { + None + } else { + Some(family) + } }; tokio::task::spawn_blocking(move || enumerate_system_fonts(current.as_deref())) .await @@ -144,7 +148,10 @@ mod tests { #[test] fn enumerate_returns_non_empty_lists() { let fonts = enumerate_system_fonts(None); - assert!(!fonts.all.is_empty(), "System should have at least one font"); + assert!( + !fonts.all.is_empty(), + "System should have at least one font" + ); assert!( !fonts.monospace.is_empty(), "System should have at least one monospace font"