From af77e5e3763258f4fdc17354c4901d8483a79a9c Mon Sep 17 00:00:00 2001 From: viny Date: Thu, 30 Jul 2026 01:44:15 +0530 Subject: [PATCH 01/11] =?UTF-8?q?release:=202.1.1=20=E2=80=94=20fix=20a=20?= =?UTF-8?q?crash=20that=20disabled=20most=20of=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderer.js still wired a click handler to btn-add-app after index.html stopped rendering it. $() returned null, addEventListener threw, and every listener registered after that line never attached: the workspace dropdown, settings, add-service, task manager, service modal, modal close buttons, keyboard shortcuts and all main-process handlers. No visible error. The renderer half of the earlier sidebar change was lost in a cherry-pick while the HTML and CSS halves landed, which is what left the mismatch. - Restore the inline add tile and drag affordance in renderer.js - Add a test asserting every $('id') in renderer.js exists in index.html, plus that index.html's scripts exist and are covered by build.files - Ad-hoc sign the macOS app in an afterPack hook. Without it the bundle kept Electron's signature, which no longer matched after renaming, so macOS reported "Panebox is damaged and can't be opened" - Never overwrite an unreadable config: quarantine it, restore from a backup written on each good load --- README.md | 3 +- lib/store.js | 29 +++++++++++- package-lock.json | 4 +- package.json | 5 +- renderer.js | 33 ++++++++++++-- test/dom-contract.test.js | 96 +++++++++++++++++++++++++++++++++++++++ test/store.test.js | 29 ++++++++++++ tools/after-pack.js | 44 ++++++++++++++++++ 8 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 test/dom-contract.test.js create mode 100644 tools/after-pack.js diff --git a/README.md b/README.md index ae291f5..509c6ad 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Download the build for your platform from [Releases](https://github.com/viny4/pa Builds are **not code-signed**, because signing certificates cost money this project doesn't have. That means: -- **macOS** will say the app "cannot be opened because the developer cannot be verified". Right-click the app → Open → Open, or run `xattr -dr com.apple.quarantine /Applications/Panebox.app`. +- **macOS** will say the app "cannot be opened because the developer cannot be verified". Right-click the app → Open → Open. + - If it instead says **"Panebox is damaged and can't be opened"**, that is Gatekeeper rejecting a quarantined app, not a bad download. Run `xattr -dr com.apple.quarantine /Applications/Panebox.app` and open it again. - **Windows** will show a SmartScreen warning. Click "More info" → "Run anyway". If that trade-off isn't acceptable to you, build from source — it takes two commands. diff --git a/lib/store.js b/lib/store.js index 6f3aea8..0258ed8 100644 --- a/lib/store.js +++ b/lib/store.js @@ -84,9 +84,36 @@ function createStore(filePath) { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); // Merge over defaults so upgrades pick up newly added settings keys. data = deepMerge(defaultConfig(), parsed); + + // Keep the last known-good copy. Losing a service list is annoying; + // losing it silently, with no way back, is worse. + try { + fs.copyFileSync(filePath, `${filePath}.backup`); + } catch { + /* a missing backup must never stop the app starting */ + } } } catch (err) { - console.error('Could not read config, starting fresh:', err.message); + // Do NOT fall through to defaults and then overwrite the file — that turns + // one bad read into permanent data loss. Preserve the original first. + const quarantined = `${filePath}.corrupt-${Date.now()}`; + try { + fs.renameSync(filePath, quarantined); + console.error(`Config unreadable (${err.message}); kept a copy at ${quarantined}`); + } catch { + console.error('Config unreadable and could not be preserved:', err.message); + } + + // Prefer the last good backup over starting from scratch. + try { + const backup = `${filePath}.backup`; + if (fs.existsSync(backup)) { + data = deepMerge(defaultConfig(), JSON.parse(fs.readFileSync(backup, 'utf8'))); + console.error('Recovered configuration from backup.'); + } + } catch { + console.error('Backup was unreadable too; starting fresh.'); + } } let writeTimer = null; diff --git a/package-lock.json b/package-lock.json index db8dee3..b2745ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "panebox", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "panebox", - "version": "2.1.0", + "version": "2.1.1", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/package.json b/package.json index 05ee072..65ee297 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "panebox", "productName": "Panebox", - "version": "2.1.0", + "version": "2.1.1", "description": "A small, private, open-source desktop deck for your web apps — AI tools, chat, mail and more in one window.", "main": "main.js", "license": "MIT", @@ -130,7 +130,8 @@ "repo": "panebox", "releaseType": "draft" } - ] + ], + "afterPack": "./tools/after-pack.js" }, "dependencies": { "electron-updater": "^6.8.9" diff --git a/renderer.js b/renderer.js index eda666a..e715583 100644 --- a/renderer.js +++ b/renderer.js @@ -151,6 +151,7 @@ function renderSidebar() { wrapper.className = 'app-tab-wrapper'; wrapper.draggable = true; wrapper.dataset.appId = app.id; + wrapper.title = 'Drag to reorder'; const tab = document.createElement('div'); tab.className = 'app-tab' + (app.id === state.activeId ? ' active' : ''); @@ -213,6 +214,31 @@ function renderSidebar() { state.tabs.set(app.id, { tab, badgeEl: badge }); } + // The add button lives with the services, not stranded in the footer — it is + // where you look when you want another one. + const addWrap = document.createElement('div'); + addWrap.className = 'app-tab-wrapper'; + + const addTile = document.createElement('button'); + addTile.className = 'app-add-tile'; + addTile.setAttribute('aria-label', 'Add a service'); + addTile.innerHTML = + '' + + ''; + addTile.addEventListener('click', () => { + renderCatalog(); + openModal('add-modal'); + }); + + const addTip = document.createElement('div'); + addTip.className = 'app-tooltip'; + const ws = activeWorkspace(); + addTip.textContent = ws && ws.appIds ? `Add to ${ws.name}` : 'Add a service'; + + addWrap.append(addTile, addTip); + list.appendChild(addWrap); + refreshBadges(); } @@ -532,6 +558,9 @@ function removeApp(appId) { let catalogCategory = 'AI'; function renderCatalog() { + const ws = activeWorkspace(); + $('add-target').textContent = ws && ws.appIds ? `Adding to "${ws.name}"` : ''; + const query = $('catalog-search').value.trim().toLowerCase(); const tabs = $('category-tabs'); const grid = $('catalog-grid'); @@ -1383,10 +1412,6 @@ document.addEventListener('DOMContentLoaded', () => { }); // --- sidebar --- - $('btn-add-app').addEventListener('click', () => { - renderCatalog(); - openModal('add-modal'); - }); $('btn-settings').addEventListener('click', () => { fillSettingsForm(); renderManageList(); diff --git a/test/dom-contract.test.js b/test/dom-contract.test.js new file mode 100644 index 0000000..60d181a --- /dev/null +++ b/test/dom-contract.test.js @@ -0,0 +1,96 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * Guards the contract between renderer.js and index.html. + * + * `$('some-id')` returns null when the element is missing, and calling + * `.addEventListener` on null throws — which aborts the rest of the + * DOMContentLoaded handler and silently kills every listener registered after + * it. That shipped once: index.html dropped a button while renderer.js kept + * wiring it, and the workspace dropdown, settings, task manager, modals and + * keyboard shortcuts all stopped working with no visible error. + * + * It is a one-line mistake with an enormous blast radius, so it gets a test. + */ + +const root = path.join(__dirname, '..'); +const renderer = fs.readFileSync(path.join(root, 'renderer.js'), 'utf8'); +const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); + +/** Every id="..." declared in index.html. */ +function declaredIds() { + const ids = new Set(); + for (const m of html.matchAll(/\bid="([^"]+)"/g)) ids.add(m[1]); + return ids; +} + +/** Every literal $('...') lookup in renderer.js, with its line number. */ +function referencedIds() { + const refs = []; + renderer.split('\n').forEach((line, i) => { + for (const m of line.matchAll(/\$\('([^']+)'\)/g)) { + refs.push({ id: m[1], line: i + 1 }); + } + }); + return refs; +} + +test('index.html declares every element renderer.js looks up', () => { + const declared = declaredIds(); + const missing = referencedIds().filter((r) => !declared.has(r.id)); + + assert.deepStrictEqual( + missing, + [], + `renderer.js references ids that index.html does not define:\n` + + missing.map((m) => ` line ${m.line}: $('${m.id}')`).join('\n') + + `\n\nThis throws at runtime and silently disables every listener wired after it.`, + ); +}); + +test('renderer.js does not reference the removed footer add button', () => { + // Specific regression: the button moved into the service list. + assert.ok( + !renderer.includes('btn-add-app'), + 'btn-add-app was replaced by the inline .app-add-tile', + ); +}); + +test('the inline add tile is wired up', () => { + assert.ok(renderer.includes('app-add-tile'), 'renderer.js must create the add tile'); + assert.ok( + fs.readFileSync(path.join(root, 'styles.css'), 'utf8').includes('.app-add-tile'), + 'styles.css must style the add tile', + ); +}); + +test('every script index.html loads actually exists', () => { + for (const m of html.matchAll(/ + diff --git a/lib/urls.js b/lib/urls.js index 009c58e..bd99e19 100644 --- a/lib/urls.js +++ b/lib/urls.js @@ -79,4 +79,58 @@ function isAuthUrl(rawUrl) { return pathMatches || hasOauthParams; } -module.exports = { isAuthUrl, AUTH_HOSTS }; +/** + * Turns whatever a user typed into a URL we are willing to load, or null. + * + * Prefixing "https://" onto anything is too permissive on its own: "not a url" + * becomes "https://not a url", which parses, gets percent-encoded, and lands a + * permanently broken service in the sidebar named "Not%20a%20url". Likewise + * "file:///etc/passwd" becomes "https://file:///etc/passwd" — harmless, but + * still a dead entry the user has to work out how to remove. + */ +function normalizeServiceUrl(raw) { + const input = String(raw == null ? '' : raw).trim(); + if (!input) return null; + + // An explicit scheme that isn't http(s) is a refusal, not something to prefix. + // "localhost:3000" is host:port, not a scheme, so a colon followed by digits + // does not count — self-hosting a service is a legitimate thing to do here. + const scheme = input.match(/^([a-z][a-z0-9+.-]*):(?!\d)/i); + if (scheme && !/^https?$/i.test(scheme[1])) return null; + + let url; + try { + url = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + if (!url.hostname) return null; + + const isIpv6 = /^\[[0-9a-f:]+\]$/i.test(url.host); + if (!isIpv6 && !/^[a-z0-9.-]+$/i.test(url.hostname)) return null; // spaces, %20, junk + if (url.hostname.startsWith('.') || url.hostname.endsWith('.')) return null; + + // Bare words aren't hosts. localhost and IP literals are. + const isIpv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(url.hostname); + if (!url.hostname.includes('.') && url.hostname !== 'localhost' && !isIpv6) return null; + if (!isIpv4 && !isIpv6 && url.hostname !== 'localhost' && !/\.[a-z]{2,}$/i.test(url.hostname)) { + return null; + } + + return url; +} + +/** A readable default name from a URL: "https://www.notion.so/x" -> "Notion". */ +function nameFromUrl(url) { + const host = url.hostname.replace(/^www\./i, ''); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || url.host.startsWith('[')) return url.host; + const label = host.split('.')[0]; + return label.charAt(0).toUpperCase() + label.slice(1); +} + +const API = { isAuthUrl, AUTH_HOSTS, normalizeServiceUrl, nameFromUrl }; + +if (typeof module === 'object' && module.exports) module.exports = API; +else if (typeof self !== 'undefined') self.URLS = API; diff --git a/renderer.js b/renderer.js index 61831d3..162006d 100644 --- a/renderer.js +++ b/renderer.js @@ -563,22 +563,18 @@ function hibernationTick() { // ---------------------------------------------------------------- add app function addApp({ name, url, serviceKey, color }) { - let finalName = (name || '').trim(); - let finalUrl = (url || '').trim(); - if (!finalUrl) return null; - if (!/^https?:\/\//i.test(finalUrl)) finalUrl = `https://${finalUrl}`; - - try { - const parsed = new URL(finalUrl); - if (!finalName) { - const host = parsed.hostname.replace(/^www\./, ''); - finalName = host.split('.')[0].replace(/^./, (c) => c.toUpperCase()); - } - } catch { - alert('That does not look like a valid URL.'); + const parsed = window.URLS.normalizeServiceUrl(url); + if (!parsed) { + alert( + `"${String(url).trim()}" is not a web address Panebox can open.\n\n` + + 'Try something like notion.so or https://example.com/app.', + ); return null; } + let finalName = (name || '').trim() || window.URLS.nameFromUrl(parsed); + const finalUrl = parsed.href; + // A second copy of the same service is a feature (two accounts), but two // rows reading "Gemini" is not — number them so they can be told apart. const sameName = apps().filter( diff --git a/test/urls.test.js b/test/urls.test.js index 7bb6958..6246087 100644 --- a/test/urls.test.js +++ b/test/urls.test.js @@ -38,3 +38,45 @@ test('rejects non-http schemes and malformed input', () => { assert.ok(!isAuthUrl('not a url')); assert.ok(!isAuthUrl('')); }); + +// --- normalizeServiceUrl ----------------------------------------------------- + +const { normalizeServiceUrl, nameFromUrl } = require('../lib/urls'); + +test('accepts a bare hostname and assumes https', () => { + assert.strictEqual(normalizeServiceUrl('notion.so').href, 'https://notion.so/'); +}); + +test('keeps an explicit http url as http', () => { + assert.strictEqual(normalizeServiceUrl('http://intranet.local/x').protocol, 'http:'); +}); + +test('rejects input that is not a web address', () => { + // "not a url" used to become https://not%20a%20url and land a dead service. + for (const bad of ['not a url', 'foo', '', ' ', null, undefined]) { + assert.strictEqual(normalizeServiceUrl(bad), null, `should reject ${JSON.stringify(bad)}`); + } +}); + +test('rejects non-http schemes', () => { + for (const bad of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,

x', 'mailto:a@b.c']) { + assert.strictEqual(normalizeServiceUrl(bad), null, `should reject ${bad}`); + } +}); + +test('allows self-hosted services on localhost and IPs', () => { + // "localhost:3000" must read as host:port, not as a scheme. + assert.strictEqual(normalizeServiceUrl('localhost:3000').href, 'https://localhost:3000/'); + assert.strictEqual(normalizeServiceUrl('http://localhost:8080').port, '8080'); + assert.ok(normalizeServiceUrl('https://192.168.1.5:8080/wiki')); +}); + +test('trims surrounding whitespace', () => { + assert.strictEqual(normalizeServiceUrl(' spaced.com ').href, 'https://spaced.com/'); +}); + +test('derives a readable name', () => { + assert.strictEqual(nameFromUrl(normalizeServiceUrl('https://www.notion.so/x')), 'Notion'); + assert.strictEqual(nameFromUrl(normalizeServiceUrl('sub.domain.co.uk')), 'Sub'); + assert.strictEqual(nameFromUrl(normalizeServiceUrl('https://192.168.1.5:8080')), '192.168.1.5:8080'); +}); From 72feac83e403dc2b6d23f5f8bb87f182f8e16658 Mon Sep 17 00:00:00 2001 From: viny Date: Thu, 30 Jul 2026 02:53:38 +0530 Subject: [PATCH 08/11] ui: get a real logo for hand-typed URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a URL by hand gave a letter avatar, then whatever favicon the page advertised first — usually 16x16, which looks like mush at 26px. Two sources now, in order: - If the URL belongs to a service already in the catalog, use its official brand mark. Typing notion.so or app.slack.com now looks identical to picking it from the list. - Otherwise read the page for something sharper: apple-touch-icon, manifest icons at 192/512, or sized link tags, largest wins. SVG beats every bitmap since it scales. All read from the page itself; nothing asked of a third party. Catalog matching weighs the path, not just the host. Gmail and Google Chat share mail.google.com, so host-only matching gave every Gmail URL the Google Chat icon. --- catalog.js | 51 ++++++++++++++++++++++++++++++++++++- renderer.js | 65 ++++++++++++++++++++++++++++++++++++++++++++--- test/urls.test.js | 27 ++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/catalog.js b/catalog.js index d4ff023..35aac55 100644 --- a/catalog.js +++ b/catalog.js @@ -89,5 +89,54 @@ return SERVICES.find((s) => s.key === key) || null; } - return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey }; + const bareHost = (value) => { + try { + return new URL(value).hostname.replace(/^www\./i, '').toLowerCase(); + } catch { + return ''; + } + }; + + const barePath = (value) => { + try { + return new URL(value).pathname.replace(/\/+$/, '') || '/'; + } catch { + return '/'; + } + }; + + /** + * Finds a catalog entry for a URL, so one typed by hand still gets the + * official brand mark instead of a letter avatar. + * + * The path matters, not just the host: Gmail and Google Chat both live on + * mail.google.com, separated only by /chat. Host-only matching handed every + * Gmail URL the Google Chat icon. + */ + function byHost(url) { + const host = bareHost(url); + if (!host) return null; + const path = barePath(url); + + const sameHost = SERVICES.filter((s) => bareHost(s.url) === host); + if (sameHost.length) { + // Most specific path that the URL actually sits under wins; '/' matches + // anything and acts as the fallback for that host. + const scored = sameHost + .map((s) => ({ service: s, prefix: barePath(s.url) })) + .filter((c) => c.prefix === '/' || path === c.prefix || path.startsWith(`${c.prefix}/`)) + .sort((a, b) => b.prefix.length - a.prefix.length); + if (scored.length) return scored[0].service; + } + + // Fall back to a subdomain relationship: app.slack.com -> Slack. + return ( + SERVICES.find((s) => { + const known = bareHost(s.url); + return known && (host.endsWith(`.${known}`) || known.endsWith(`.${host}`)); + }) || null + ); + } + + return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey, byHost }; }); diff --git a/renderer.js b/renderer.js index 162006d..858d56b 100644 --- a/renderer.js +++ b/renderer.js @@ -295,6 +295,58 @@ function commitSidebarOrder() { saveApps(); } +// -------------------------------------------------------------- icons (page) + +/** + * Looks for a high-resolution logo inside the page. + * + * The favicon a page advertises first is typically 16x16 and looks like mush in + * the sidebar. Sites almost always ship something better — an apple-touch-icon + * at 180px, or manifest icons at 192/512 — so prefer the largest we can find. + * Everything is read from the page itself; nothing is asked of a third party. + */ +async function findBestIcon(app, wv) { + const script = `(async () => { + const out = []; + // An SVG scales to any size, so it beats every bitmap regardless of what + // dimensions the page declares. + const push = (href, size) => { + if (!href) return; + const abs = new URL(href, location.href).href; + out.push({ href: abs, size: /\\.svg(\\?|#|$)/i.test(abs) ? 1024 : size }); + }; + + for (const link of document.querySelectorAll('link[rel~="icon"], link[rel~="apple-touch-icon"], link[rel~="apple-touch-icon-precomposed"], link[rel~="shortcut"]')) { + const declared = parseInt((link.getAttribute('sizes') || '').split(/[x\\s]/)[0], 10); + const isApple = (link.getAttribute('rel') || '').includes('apple'); + push(link.getAttribute('href'), Number.isFinite(declared) ? declared : (isApple ? 180 : 32)); + } + + const manifest = document.querySelector('link[rel="manifest"]'); + if (manifest) { + try { + const res = await fetch(new URL(manifest.getAttribute('href'), location.href), { credentials: 'omit' }); + const data = await res.json(); + for (const icon of data.icons || []) { + const declared = parseInt(String(icon.sizes || '').split(/[x\\s]/)[0], 10); + push(icon.src, Number.isFinite(declared) ? declared : 64); + } + } catch (e) { /* no manifest, or blocked — the link tags are enough */ } + } + + out.sort((a, b) => b.size - a.size); + return out[0] && out[0].size >= 48 ? out[0] : null; + })()`; + + const best = await withWebview(app.id, (view) => view.executeJavaScript(script, false), null); + if (!best || !best.href || best.href === app.favicon) return; + + app.favicon = best.href; + app.faviconHiRes = true; + saveApps(); + renderSidebar(); +} + // -------------------------------------------------------------- webviews function createWebview(app) { @@ -312,6 +364,7 @@ function createWebview(app) { console.warn(`[${app.name}] custom JS failed:`, err && err.message); }); if (app.id === state.activeId) syncUrl(); + if (!app.serviceKey) findBestIcon(app, wv); }); const syncUrl = () => { @@ -344,7 +397,9 @@ function createWebview(app) { wv.addEventListener('page-favicon-updated', (e) => { const icon = e.favicons && e.favicons[0]; - if (!icon || icon === app.favicon) return; + // Only a starting point: the default favicon is usually 16x16 and looks + // muddy at 26px. dom-ready then hunts for something sharper. + if (!icon || icon === app.favicon || app.faviconHiRes) return; app.favicon = icon; saveApps(); renderSidebar(); @@ -582,12 +637,16 @@ function addApp({ name, url, serviceKey, color }) { ).length; if (sameName > 0) finalName = `${finalName} ${sameName + 1}`; + // A hand-typed URL that happens to be a service we know should still get the + // official mark rather than a letter avatar. + const known = serviceKey ? null : window.CATALOG.byHost(finalUrl); + const app = { id: `app-${Date.now()}-${Math.floor(Math.random() * 10000)}`, name: finalName, url: finalUrl, - serviceKey: serviceKey || null, - color: color || null, + serviceKey: serviceKey || (known && known.key) || null, + color: color || (known && known.color) || null, favicon: null, session: 'isolated', notifications: true, diff --git a/test/urls.test.js b/test/urls.test.js index 6246087..6961fdb 100644 --- a/test/urls.test.js +++ b/test/urls.test.js @@ -80,3 +80,30 @@ test('derives a readable name', () => { assert.strictEqual(nameFromUrl(normalizeServiceUrl('sub.domain.co.uk')), 'Sub'); assert.strictEqual(nameFromUrl(normalizeServiceUrl('https://192.168.1.5:8080')), '192.168.1.5:8080'); }); + +// --- catalog lookup by URL --------------------------------------------------- + +const CATALOG = require('../catalog'); + +test('a hand-typed URL resolves to a known service', () => { + assert.strictEqual(CATALOG.byHost('https://notion.so').key, 'notion'); + assert.strictEqual(CATALOG.byHost('https://linear.app').key, 'linear'); +}); + +test('matches subdomains of a known service', () => { + assert.strictEqual(CATALOG.byHost('https://app.slack.com/client').key, 'slack'); + assert.strictEqual(CATALOG.byHost('https://music.youtube.com').key, 'youtubemusic'); +}); + +test('uses the path to separate services that share a host', () => { + // Gmail and Google Chat are both on mail.google.com; host-only matching gave + // every Gmail URL the Google Chat icon. + assert.strictEqual(CATALOG.byHost('https://mail.google.com').key, 'gmail'); + assert.strictEqual(CATALOG.byHost('https://mail.google.com/x').key, 'gmail'); + assert.strictEqual(CATALOG.byHost('https://mail.google.com/chat/abc').key, 'googlechat'); +}); + +test('returns null for an unknown site', () => { + assert.strictEqual(CATALOG.byHost('https://news.ycombinator.com'), null); + assert.strictEqual(CATALOG.byHost('not a url'), null); +}); From c12d1b6f533b33455c90e312f8da825ef45e1f5b Mon Sep 17 00:00:00 2001 From: viny Date: Thu, 30 Jul 2026 03:05:07 +0530 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20split=20view=20=E2=80=94=20every?= =?UTF-8?q?=20service=20in=20a=20group=20on=20screen=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd/Ctrl+Shift+S lays the current workspace out as a grid, so a group doubles as a dashboard: make an "AI" group and see ChatGPT, Claude, Gemini and Perplexity side by side. - Columns follow the shape video-call grids settle on, keeping tiles near square: 2 up to 4 panes, 3 up to 9, 4 up to 16. Capped at 12, since each live pane costs memory. - Click a pane's expand button to spotlight it: that pane takes the room and the rest become a thumbnail strip underneath. - Escape steps back out — first the spotlight, then the grid. - Panes in the grid are exempt from sleeping while it is open. Each webview now sits in a pane wrapper built at creation time. Wrappers are never moved afterwards: relocating a in the DOM tears down its process and reloads the page. Sessions are untouched. Panes are only shown and hidden, and nothing here clears a partition, so logins survive entering and leaving the grid. --- index.html | 3 ++ main.js | 1 + preload.js | 1 + renderer.js | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++-- styles.css | 60 ++++++++++++++++++++++ 5 files changed, 204 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 471be5e..3737291 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,9 @@
+ diff --git a/main.js b/main.js index e2a71c2..7340fc1 100644 --- a/main.js +++ b/main.js @@ -451,6 +451,7 @@ function buildAppMenu() { { label: 'Previous Service', accelerator: 'CmdOrCtrl+Shift+Tab', click: () => send('pb:cycle', -1) }, { type: 'separator' }, { label: 'Toggle Sidebar', accelerator: 'CmdOrCtrl+B', click: () => send('pb:toggle-sidebar') }, + { label: 'Split View', accelerator: 'CmdOrCtrl+Shift+S', click: () => send('pb:toggle-split') }, { label: 'Toggle Todo Panel', accelerator: 'CmdOrCtrl+T', click: () => send('pb:toggle-todo') }, { label: 'Task Manager', accelerator: 'CmdOrCtrl+Shift+M', click: () => send('pb:open-taskmanager') }, { type: 'separator' }, diff --git a/preload.js b/preload.js index 369a646..eddbc74 100644 --- a/preload.js +++ b/preload.js @@ -75,6 +75,7 @@ contextBridge.exposeInMainWorld('panebox', { onOpenFind: on('pb:open-find'), onOpenTaskManager: on('pb:open-taskmanager'), onToggleSidebar: on('pb:toggle-sidebar'), + onToggleSplit: on('pb:toggle-split'), onToggleTodo: on('pb:toggle-todo'), onReloadActive: on('pb:reload-active'), onCycle: on('pb:cycle'), diff --git a/renderer.js b/renderer.js index 858d56b..a9d95b6 100644 --- a/renderer.js +++ b/renderer.js @@ -18,6 +18,9 @@ const state = { config: null, activeId: null, webviews: new Map(), // appId -> + panes: new Map(), // appId -> wrapper element holding the webview + split: false, // grid mode: every service in the workspace on screen at once + focusedId: null, // in grid mode, the pane spotlighted at full size ready: new Set(), // appIds whose webview has emitted dom-ready tabs: new Map(), // appId -> { tab, badgeEl } badges: new Map(), // appId -> number (-1 means "dot") @@ -295,6 +298,82 @@ function commitSidebarOrder() { saveApps(); } +// ------------------------------------------------------------------ grid mode + +/** + * Columns for n panes, following the shape video-call grids settle on: keep + * tiles as close to square as the space allows rather than stretching them. + */ +function gridColumns(count) { + if (count <= 1) return 1; + if (count <= 4) return 2; + if (count <= 9) return 3; + if (count <= 16) return 4; + return 5; +} + +/** Sessions are never touched here — panes only show and hide. */ +function setSplit(on) { + state.split = !!on; + if (!state.split) state.focusedId = null; + + const container = $('view-container'); + container.classList.toggle('split', state.split); + $('btn-split').classList.toggle('on', state.split); + + if (!state.split) { + for (const [id, pane] of state.panes) { + pane.classList.remove('focused', 'thumb'); + pane.classList.toggle('active', id === state.activeId); + } + container.style.removeProperty('--grid-cols'); + return; + } + + // Everything in the current workspace goes on screen, so a group doubles as + // a dashboard. Waking them all costs memory, hence the cap. + const wanted = visibleApps().slice(0, 12); + for (const app of wanted) { + if (!state.webviews.has(app.id)) createWebview(app); + state.lastActive.set(app.id, Date.now()); + } + + const shown = new Set(wanted.map((a) => a.id)); + for (const [id, pane] of state.panes) { + pane.classList.toggle('active', shown.has(id)); + } + container.style.setProperty('--grid-cols', gridColumns(shown.size)); + applySplitFocus(); +} + +function setSplitFocus(appId) { + if (!state.split) return; + state.focusedId = appId; + applySplitFocus(); +} + +function applySplitFocus() { + const container = $('view-container'); + const focused = state.focusedId; + container.classList.toggle('has-focus', !!focused); + + let thumbs = 0; + for (const [id, pane] of state.panes) { + const isThumb = !!focused && id !== focused && pane.classList.contains('active'); + pane.classList.toggle('focused', id === focused); + pane.classList.toggle('thumb', isThumb); + if (isThumb) thumbs++; + } + + // The thumbnail strip needs one column per thumbnail; the focused pane spans + // all of them on the row above. + container.style.setProperty('--thumb-count', Math.max(thumbs, 1)); +} + +function toggleSplit() { + setSplit(!state.split); +} + // -------------------------------------------------------------- icons (page) /** @@ -415,7 +494,47 @@ function createWebview(app) { console.warn(`[${app.name}] failed to load: ${e.errorDescription} (${e.validatedURL})`); }); - $('view-container').appendChild(wv); + // The webview lives inside a pane wrapper so grid mode can give it a header. + // Built at creation, never moved afterwards: relocating a in the + // DOM tears down its process and reloads the page. + const pane = document.createElement('div'); + pane.className = 'pane'; + pane.dataset.appId = app.id; + + const head = document.createElement('div'); + head.className = 'pane-head'; + + const label = document.createElement('span'); + label.className = 'pane-name'; + label.textContent = app.name; + + const focusBtn = document.createElement('button'); + focusBtn.className = 'pane-btn'; + focusBtn.title = 'Expand'; + focusBtn.textContent = '⤢'; + focusBtn.addEventListener('click', (e) => { + e.stopPropagation(); + setSplitFocus(state.focusedId === app.id ? null : app.id); + }); + + const openBtn = document.createElement('button'); + openBtn.className = 'pane-btn'; + openBtn.title = 'Open on its own'; + openBtn.textContent = '↗'; + openBtn.addEventListener('click', (e) => { + e.stopPropagation(); + setSplit(false); + switchTab(app.id); + }); + + head.append(label, focusBtn, openBtn); + head.addEventListener('dblclick', () => + setSplitFocus(state.focusedId === app.id ? null : app.id), + ); + + pane.append(head, wv); + $('view-container').appendChild(pane); + state.panes.set(app.id, pane); state.webviews.set(app.id, wv); state.lastActive.set(app.id, Date.now()); return wv; @@ -424,7 +543,10 @@ function createWebview(app) { function destroyWebview(appId) { const wv = state.webviews.get(appId); if (!wv) return; - wv.remove(); + const pane = state.panes.get(appId); + if (pane) pane.remove(); + else wv.remove(); + state.panes.delete(appId); state.webviews.delete(appId); state.ready.delete(appId); state.badges.delete(appId); @@ -442,8 +564,9 @@ function switchTab(appId) { if (!state.webviews.has(appId)) createWebview(app); - for (const [id, view] of state.webviews) { - view.classList.toggle('active', id === appId); + if (state.split) setSplit(false); + for (const [id, pane] of state.panes) { + pane.classList.toggle('active', id === appId); } for (const [id, { tab }] of state.tabs) { tab.classList.toggle('active', id === appId); @@ -604,6 +727,7 @@ function hibernationTick() { for (const [appId] of state.webviews) { if (appId === state.activeId) continue; + if (state.split && state.panes.get(appId)?.classList.contains('active')) continue; const app = appById(appId); if (!app || app.hibernate === false) continue; const last = state.lastActive.get(appId) || now; @@ -1154,6 +1278,9 @@ function saveServiceModal() { if (state.activeId === app.id) switchTab(app.id); } + const pane = state.panes.get(app.id); + if (pane) pane.querySelector('.pane-name').textContent = app.name; + renderSidebar(); renderManageList(); closeModal('service-modal'); @@ -1557,6 +1684,7 @@ document.addEventListener('DOMContentLoaded', () => { updateDndButton(); $('set-dnd').checked = next; }); + $('btn-split').addEventListener('click', toggleSplit); $('btn-find').addEventListener('click', openFind); $('btn-todo').addEventListener('click', () => { $('todo-panel').hidden = !$('todo-panel').hidden; @@ -1679,6 +1807,8 @@ document.addEventListener('DOMContentLoaded', () => { $('workspace-menu').hidden = true; return; } + if (state.focusedId) return setSplitFocus(null); + if (state.split) return setSplit(false); if (!$('find-bar').hidden) return closeFind(); document.querySelectorAll('.modal-overlay.active').forEach((m) => { if (m.id !== 'screen-modal') closeModal(m.id); @@ -1687,7 +1817,10 @@ document.addEventListener('DOMContentLoaded', () => { } if (!(e.metaKey || e.ctrlKey)) return; - if (e.key.toLowerCase() === 'b' && !e.shiftKey) { + if (e.shiftKey && e.key.toLowerCase() === 's') { + e.preventDefault(); + toggleSplit(); + } else if (e.key.toLowerCase() === 'b' && !e.shiftKey) { e.preventDefault(); toggleSidebar(); } else if (e.shiftKey && e.key.toLowerCase() === 'r') { @@ -1758,6 +1891,7 @@ document.addEventListener('DOMContentLoaded', () => { window.panebox.menu.onOpenFind(openFind); window.panebox.menu.onOpenTaskManager(startTaskManager); window.panebox.menu.onToggleSidebar(toggleSidebar); + window.panebox.menu.onToggleSplit(toggleSplit); window.panebox.menu.onToggleTodo(() => { $('todo-panel').hidden = !$('todo-panel').hidden; }); diff --git a/styles.css b/styles.css index bd462d1..814e2c2 100644 --- a/styles.css +++ b/styles.css @@ -615,3 +615,63 @@ body.reordering webview { pointer-events: none; } } .row-grip:hover { color: var(--text); } .manage-app-row.dragging { opacity: .5; background: var(--bg-hover); } + +/* ------------------------------------------------------------- split view */ + +/* Each webview lives in a pane so grid mode can give it a header. In single + mode the pane is invisible chrome filling the workspace. */ +.pane { + position: absolute; inset: 0; + display: flex; flex-direction: column; + visibility: hidden; min-width: 0; min-height: 0; +} +.pane.active { visibility: visible; } +.pane webview { flex: 1; width: 100%; height: 100%; border: none; visibility: visible; } +.pane-head { display: none; } + +/* Grid mode: tiles sized like a video call, squarish and evenly divided. */ +#view-container.split { + display: grid; + grid-template-columns: repeat(var(--grid-cols, 2), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 8px; padding: 8px; +} +#view-container.split .pane { + position: relative; inset: auto; + border: 1px solid var(--border); border-radius: var(--radius-md); + overflow: hidden; background: var(--bg-panel); + transition: box-shadow .15s ease, border-color .15s ease; +} +#view-container.split .pane:not(.active) { display: none; } +#view-container.split .pane:hover { border-color: var(--accent); } + +#view-container.split .pane-head { + display: flex; align-items: center; gap: 6px; + padding: 5px 6px 5px 10px; flex-shrink: 0; + background: var(--bg-sidebar); border-bottom: 1px solid var(--border); + font-size: 11.5px; color: var(--text-muted); cursor: default; +} +.pane-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pane-btn { + width: 22px; height: 22px; flex-shrink: 0; + background: none; border: none; border-radius: 5px; + color: var(--text-dim); font-size: 12px; cursor: pointer; line-height: 1; +} +.pane-btn:hover { background: var(--bg-hover); color: var(--text); } + +/* Spotlight: one pane takes the room, the rest become a strip underneath — + the same shape a call takes when someone shares their screen. + Two explicit rows: the focused pane spans every column of the first, and the + thumbnails auto-place across the second. A flex column was wrong here — the + thumbnails stacked and squeezed the focused pane down to a couple of pixels. */ +#view-container.split.has-focus { + display: grid; + grid-template-columns: repeat(var(--thumb-count, 1), minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr) 132px; +} +#view-container.split.has-focus .pane.focused { + grid-row: 1; grid-column: 1 / -1; + border-color: var(--accent); box-shadow: var(--glow); +} +#view-container.split.has-focus .pane.thumb { grid-row: 2; } +#view-container.split.has-focus .pane.thumb .pane-name { font-size: 11px; } From d973b7619d123ccb895b10999868581d183814e2 Mon Sep 17 00:00:00 2001 From: viny Date: Thu, 30 Jul 2026 03:14:15 +0530 Subject: [PATCH 10/11] feat: choose which services go in the split, individually or by group The grid took whatever the workspace held, which is wrong when you want two things side by side and nothing else. It is now an explicit selection: - Click a sidebar icon while the grid is open to add or remove that service. Icons in the grid are outlined, and their tooltip says which action a click performs. - Every pane has a close button that drops it from the grid. - A "+" tile opens a picker listing everything not on screen, with "Add all N from " at the top for the dashboard case. - Opens with two panes rather than the whole workspace, and the selection is remembered between launches. Also fixes headers being invisible in loaded panes: the original `webview { position: absolute; inset: 0 }` still applied inside a pane, so a painted page covered its own header. Unloaded panes showed theirs, which made it look intermittent. --- index.html | 1 + lib/store.js | 1 + renderer.js | 175 +++++++++++++++++++++++++++++++++++++++++++++++---- styles.css | 41 +++++++++++- 4 files changed, 204 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 3737291..7d4539c 100644 --- a/index.html +++ b/index.html @@ -316,6 +316,7 @@

Choose what to share

+ diff --git a/lib/store.js b/lib/store.js index 7fd8671..27521a8 100644 --- a/lib/store.js +++ b/lib/store.js @@ -44,6 +44,7 @@ function defaultConfig() { workspaces: [{ id: 'ws-all', name: 'All', appIds: null }], activeWorkspace: 'ws-all', todos: [], + splitIds: [], window: null, settings: { theme: 'system', diff --git a/renderer.js b/renderer.js index a9d95b6..a107f0e 100644 --- a/renderer.js +++ b/renderer.js @@ -19,7 +19,8 @@ const state = { activeId: null, webviews: new Map(), // appId -> panes: new Map(), // appId -> wrapper element holding the webview - split: false, // grid mode: every service in the workspace on screen at once + split: false, // grid mode + splitIds: new Set(), // which services are in the grid — chosen, not implied focusedId: null, // in grid mode, the pane spotlighted at full size ready: new Set(), // appIds whose webview has emitted dom-ready tabs: new Map(), // appId -> { tab, badgeEl } @@ -178,11 +179,20 @@ function renderSidebar() { } }); + if (state.split) { + tab.classList.toggle('in-split', state.splitIds.has(app.id)); + } + const tooltip = document.createElement('div'); tooltip.className = 'app-tooltip'; - tooltip.textContent = app.name; + tooltip.textContent = state.split + ? `${state.splitIds.has(app.id) ? 'Remove from' : 'Add to'} split — ${app.name}` + : app.name; - tab.addEventListener('click', () => switchTab(app.id)); + tab.addEventListener('click', () => { + if (state.split) toggleInSplit(app.id); + else switchTab(app.id); + }); tab.addEventListener('contextmenu', (e) => { e.preventDefault(); openServiceModal(app.id); @@ -322,6 +332,8 @@ function setSplit(on) { $('btn-split').classList.toggle('on', state.split); if (!state.split) { + const adder = container.querySelector('.pane-add'); + if (adder) adder.classList.remove('active'); for (const [id, pane] of state.panes) { pane.classList.remove('focused', 'thumb'); pane.classList.toggle('active', id === state.activeId); @@ -330,20 +342,144 @@ function setSplit(on) { return; } - // Everything in the current workspace goes on screen, so a group doubles as - // a dashboard. Waking them all costs memory, hence the cap. - const wanted = visibleApps().slice(0, 12); - for (const app of wanted) { - if (!state.webviews.has(app.id)) createWebview(app); - state.lastActive.set(app.id, Date.now()); + // Drop anything that has since been removed, then seed if this is the first + // time: the service you are on, plus the next couple from the workspace, so + // the grid opens with something in it rather than a single lonely pane. + state.splitIds = new Set([...state.splitIds].filter((id) => appById(id))); + if (state.splitIds.size < 2) { + const seed = [state.activeId, ...visibleApps().map((a) => a.id)].filter(Boolean); + for (const id of seed) { + if (state.splitIds.size >= 2) break; + state.splitIds.add(id); + } } - const shown = new Set(wanted.map((a) => a.id)); + renderSplit(); +} + +/** Wakes the chosen services, shows only those panes, and sizes the grid. */ +function renderSplit() { + const container = $('view-container'); + const ids = [...state.splitIds].slice(0, 12); + + for (const id of ids) { + const app = appById(id); + if (!app) continue; + if (!state.webviews.has(id)) createWebview(app); + state.lastActive.set(id, Date.now()); + } + + const shown = new Set(ids); for (const [id, pane] of state.panes) { pane.classList.toggle('active', shown.has(id)); } - container.style.setProperty('--grid-cols', gridColumns(shown.size)); + + // A "+" tile so services can be added from inside the grid, not only from + // the sidebar. + let adder = container.querySelector('.pane-add'); + if (!adder) { + adder = document.createElement('button'); + adder.className = 'pane pane-add'; + adder.innerHTML = + '' + + 'Add a pane'; + adder.addEventListener('click', (e) => { + e.stopPropagation(); + openSplitMenu(adder); + }); + container.appendChild(adder); + } + container.appendChild(adder); // keep it last + adder.classList.toggle('active', shown.size < 12 && !state.focusedId); + + if (state.focusedId && !shown.has(state.focusedId)) state.focusedId = null; + container.style.setProperty('--grid-cols', gridColumns(shown.size + (shown.size < 12 ? 1 : 0))); applySplitFocus(); + renderSidebar(); + saveSplit(); +} + +/** + * Picker for the "+" tile: everything not already on screen, plus a shortcut to + * drop the whole current group in at once. Both routes matter — sometimes you + * want two things side by side, sometimes the entire group as a dashboard. + */ +function openSplitMenu(anchor) { + const menu = $('split-menu'); + menu.textContent = ''; + + const ws = activeWorkspace(); + const groupIds = visibleApps().map((a) => a.id); + const missingFromGroup = groupIds.filter((id) => !state.splitIds.has(id)); + + if (missingFromGroup.length > 1) { + const all = document.createElement('button'); + all.className = 'popover-manage'; + all.textContent = `Add all ${missingFromGroup.length} from "${ws ? ws.name : 'All'}"`; + all.addEventListener('click', () => { + for (const id of missingFromGroup) { + if (state.splitIds.size >= 12) break; + state.splitIds.add(id); + } + menu.hidden = true; + renderSplit(); + }); + menu.appendChild(all); + + const sep = document.createElement('div'); + sep.className = 'popover-sep'; + menu.appendChild(sep); + } + + const candidates = apps().filter((a) => !state.splitIds.has(a.id)); + if (!candidates.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.style.fontSize = '12px'; + empty.textContent = 'Every service is already on screen.'; + menu.appendChild(empty); + } + + for (const app of candidates) { + const row = document.createElement('button'); + row.className = 'popover-item'; + const icon = iconNode(app); + icon.classList.add('popover-icon'); + row.append(icon, document.createTextNode(app.name)); + row.addEventListener('click', () => { + menu.hidden = true; + toggleInSplit(app.id); + }); + menu.appendChild(row); + } + + const rect = anchor.getBoundingClientRect(); + menu.style.left = `${Math.min(rect.left, window.innerWidth - 250)}px`; + menu.style.top = `${Math.min(rect.top + 8, window.innerHeight - 320)}px`; + menu.hidden = false; +} + +function saveSplit() { + window.panebox.config.setKey('splitIds', [...state.splitIds]); +} + +/** + * Adds or removes one service from the grid. + * + * This is what makes the grid a choice rather than a consequence of the + * workspace: two services side by side is the common case, not nine. + */ +function toggleInSplit(appId) { + if (state.splitIds.has(appId)) { + if (state.splitIds.size <= 1) return setSplit(false); // last pane closes the grid + state.splitIds.delete(appId); + if (state.focusedId === appId) state.focusedId = null; + } else { + if (state.splitIds.size >= 12) return; + state.splitIds.add(appId); + } + renderSplit(); } function setSplitFocus(appId) { @@ -527,7 +663,16 @@ function createWebview(app) { switchTab(app.id); }); - head.append(label, focusBtn, openBtn); + const closeBtn = document.createElement('button'); + closeBtn.className = 'pane-btn'; + closeBtn.title = 'Remove from split'; + closeBtn.textContent = '✕'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleInSplit(app.id); + }); + + head.append(label, focusBtn, openBtn, closeBtn); head.addEventListener('dblclick', () => setSplitFocus(state.focusedId === app.id ? null : app.id), ); @@ -1634,6 +1779,7 @@ async function init() { applyTheme(await window.panebox.theme.isDark()); applySidebarVisibility(settings().sidebarHidden); + state.splitIds = new Set((state.config.splitIds || []).filter((id) => appById(id))); fillSettingsForm(); updateDndButton(); bindSettingsControls(); @@ -1727,8 +1873,10 @@ document.addEventListener('DOMContentLoaded', () => { }); // Clicks inside the popover must not dismiss it — you type a name in there. $('workspace-menu').addEventListener('click', (e) => e.stopPropagation()); + $('split-menu').addEventListener('click', (e) => e.stopPropagation()); document.addEventListener('click', () => { $('workspace-menu').hidden = true; + $('split-menu').hidden = true; }); // --- add modal --- @@ -1803,8 +1951,9 @@ document.addEventListener('DOMContentLoaded', () => { // --- keyboard --- window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { - if (!$('workspace-menu').hidden) { + if (!$('workspace-menu').hidden || !$('split-menu').hidden) { $('workspace-menu').hidden = true; + $('split-menu').hidden = true; return; } if (state.focusedId) return setSplitFocus(null); diff --git a/styles.css b/styles.css index 814e2c2..31181de 100644 --- a/styles.css +++ b/styles.css @@ -626,7 +626,14 @@ body.reordering webview { pointer-events: none; } visibility: hidden; min-width: 0; min-height: 0; } .pane.active { visibility: visible; } -.pane webview { flex: 1; width: 100%; height: 100%; border: none; visibility: visible; } +.pane webview { + /* The original rule pinned every webview to position:absolute inset:0, which + inside a pane means it covers its own header — visible only once the page + paints, so the header appeared to exist and then vanish. */ + position: relative; inset: auto; + flex: 1; min-height: 0; width: 100%; height: auto; + border: none; visibility: visible; +} .pane-head { display: none; } /* Grid mode: tiles sized like a video call, squarish and evenly divided. */ @@ -675,3 +682,35 @@ body.reordering webview { pointer-events: none; } } #view-container.split.has-focus .pane.thumb { grid-row: 2; } #view-container.split.has-focus .pane.thumb .pane-name { font-size: 11px; } + +/* Which services are currently in the grid. */ +.app-tab.in-split { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } +.app-tab.in-split::after { + content: ''; position: absolute; bottom: -3px; left: 50%; transform: translateX(-50%); + width: 14px; height: 3px; border-radius: 2px; background: var(--accent); +} + +/* "+" tile inside the grid. */ +.pane-add { + display: none; align-items: center; justify-content: center; + flex-direction: column; gap: 8px; + background: transparent; border: 1px dashed var(--border); + border-radius: var(--radius-md); color: var(--text-dim); + cursor: pointer; font-family: inherit; font-size: 12px; + transition: all .18s ease; +} +#view-container.split .pane-add.active { display: flex; } +.pane-add:hover { border-color: var(--accent); color: var(--accent); border-style: solid; } +#view-container.split.has-focus .pane-add { display: none; } + +.popover-item { + display: flex; align-items: center; gap: 9px; width: 100%; + padding: 7px 10px; background: none; border: none; border-radius: 6px; + color: var(--text); font-size: 12.5px; font-family: inherit; + cursor: pointer; text-align: left; +} +.popover-item:hover { background: var(--bg-hover); } +.popover-icon, .popover-item .avatar { + width: 18px; height: 18px; flex-shrink: 0; border-radius: 4px; font-size: 8px; +} +#split-menu { max-height: 320px; overflow-y: auto; } From 9aa395474d93a97cd7fa907fc0b4436f03321c17 Mon Sep 17 00:00:00 2001 From: viny Date: Thu, 30 Jul 2026 03:25:24 +0530 Subject: [PATCH 11/11] feat: drag services into the grid, click-depth to expand or detach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag a sidebar icon onto the grid to add it as a pane. The grid outlines itself and says "Drop to add a pane" while you are over it. Reuses the pointer-drag already used for reordering: leaving the sidebar switches the gesture from reorder to add. - Double-click a pane header to expand it; a third click in the same burst opens that service on its own and leaves the grid. Also fixes the grid wasting a cell. The "+" tile was a grid item, so two services shared a row with a large empty square beside them and each pane got half the height it should have. It now floats over the bottom-right corner, and columns are computed from the panes alone — two panes go from 600x389 to 600x786. The float needed `#view-container .pane-add` to outweigh `#view-container.split .pane`, which had been forcing it back into the flow. --- renderer.js | 49 ++++++++++++++++++++++++++++++++++++++++++++----- styles.css | 34 ++++++++++++++++++++++++++-------- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/renderer.js b/renderer.js index a107f0e..9672e7c 100644 --- a/renderer.js +++ b/renderer.js @@ -252,14 +252,27 @@ function beginReorder(event, wrapper, listEl, commit, itemSelector) { const startY = event.clientY; let dragging = false; + const sidebarRight = $('sidebar').getBoundingClientRect().right; + const appId = wrapper.dataset.appId; + const canDropIntoGrid = state.split && appId && !state.splitIds.has(appId); + let overGrid = false; + const onMove = (e) => { if (!dragging) { - if (Math.abs(e.clientY - startY) < 6) return; // still a click, not a drag + if (Math.abs(e.clientY - startY) < 6 && Math.abs(e.clientX - startX) < 6) return; dragging = true; wrapper.classList.add('dragging'); document.body.classList.add('reordering'); } + // Dragged out of the sidebar and into the grid: that means "add a pane", + // not "reorder". + if (canDropIntoGrid) { + overGrid = e.clientX > sidebarRight + 12; + $('view-container').classList.toggle('drop-target', overGrid); + if (overGrid) return; // don't shuffle the sidebar while aiming at the grid + } + const others = [...list.querySelectorAll(selector)].filter((n) => n !== wrapper); const before = others.find((n) => { const r = n.getBoundingClientRect(); @@ -278,9 +291,16 @@ function beginReorder(event, wrapper, listEl, commit, itemSelector) { const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); + $('view-container').classList.remove('drop-target'); if (!dragging) return; wrapper.classList.remove('dragging'); document.body.classList.remove('reordering'); + + if (overGrid && canDropIntoGrid) { + renderSidebar(); // undo any shuffling that happened on the way out + toggleInSplit(appId); + return; + } onCommit(); }; @@ -393,8 +413,10 @@ function renderSplit() { container.appendChild(adder); // keep it last adder.classList.toggle('active', shown.size < 12 && !state.focusedId); + // Sized from the panes alone. Counting the add tile as a cell left two + // services sharing a row with a large empty square beside them. if (state.focusedId && !shown.has(state.focusedId)) state.focusedId = null; - container.style.setProperty('--grid-cols', gridColumns(shown.size + (shown.size < 12 ? 1 : 0))); + container.style.setProperty('--grid-cols', gridColumns(shown.size)); applySplitFocus(); renderSidebar(); saveSplit(); @@ -673,9 +695,26 @@ function createWebview(app) { }); head.append(label, focusBtn, openBtn, closeBtn); - head.addEventListener('dblclick', () => - setSplitFocus(state.focusedId === app.id ? null : app.id), - ); + // Double-click expands. A third click inside the same burst opens the + // service on its own — the natural "keep going" gesture. + let clicks = 0; + let clickTimer = null; + head.addEventListener('click', (e) => { + if (e.target.closest('.pane-btn')) return; + clicks++; + clearTimeout(clickTimer); + if (clicks === 2) { + setSplitFocus(state.focusedId === app.id ? null : app.id); + } else if (clicks >= 3) { + clicks = 0; + setSplit(false); + switchTab(app.id); + return; + } + clickTimer = setTimeout(() => { + clicks = 0; + }, 450); + }); pane.append(head, wv); $('view-container').appendChild(pane); diff --git a/styles.css b/styles.css index 31181de..ca3c96a 100644 --- a/styles.css +++ b/styles.css @@ -690,19 +690,37 @@ body.reordering webview { pointer-events: none; } width: 14px; height: 3px; border-radius: 2px; background: var(--accent); } -/* "+" tile inside the grid. */ +/* "+" control. Floats over the grid rather than taking a cell — as a cell it + left a large empty square next to two panes. */ +#view-container .pane-add, .pane-add { - display: none; align-items: center; justify-content: center; - flex-direction: column; gap: 8px; - background: transparent; border: 1px dashed var(--border); - border-radius: var(--radius-md); color: var(--text-dim); + display: none; position: absolute; right: 14px; bottom: 14px; z-index: 20; + align-items: center; gap: 7px; padding: 8px 14px; + background: var(--bg-panel); border: 1px solid var(--border); + border-radius: 999px; color: var(--text-muted); cursor: pointer; font-family: inherit; font-size: 12px; - transition: all .18s ease; + box-shadow: var(--shadow); transition: all .18s ease; +} +.pane-add svg { width: 15px; height: 15px; } +#view-container.split .pane-add.active { + display: flex; position: absolute; + right: 14px; bottom: 14px; left: auto; top: auto; + width: auto; height: auto; flex: 0 0 auto; + border-style: solid; background: var(--bg-panel); } -#view-container.split .pane-add.active { display: flex; } -.pane-add:hover { border-color: var(--accent); color: var(--accent); border-style: solid; } +#view-container.split .pane-add:hover { border-color: var(--accent); color: var(--accent); } #view-container.split.has-focus .pane-add { display: none; } +/* Dragging a sidebar icon over the grid. */ +#view-container.drop-target { outline: 2px dashed var(--accent); outline-offset: -6px; } +#view-container.drop-target::after { + content: 'Drop to add a pane'; + position: absolute; left: 50%; top: 16px; transform: translateX(-50%); + padding: 6px 14px; border-radius: 999px; z-index: 30; + background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; + pointer-events: none; +} + .popover-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 10px; background: none; border: none; border-radius: 6px;