From 1dcede19bd098498be88a9daa4d78867e8447386 Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:18:29 -0600 Subject: [PATCH 1/6] feat(network): filter requests by URL search and type --- src/renderer/overlay/NetworkPanel.tsx | 55 +++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/renderer/overlay/NetworkPanel.tsx b/src/renderer/overlay/NetworkPanel.tsx index 300d7cb..bdb1602 100644 --- a/src/renderer/overlay/NetworkPanel.tsx +++ b/src/renderer/overlay/NetworkPanel.tsx @@ -6,13 +6,24 @@ import { Throughput } from './Throughput' import { RequestDetail } from './RequestDetail' // Panel Network (D-015 / REQ-020): stats + throughput (Canvas) + tabla virtualizada + detalle. +const TYPES = ['all', 'fetch', 'js', 'css', 'img', 'doc', 'other'] as const +type TypeFilter = (typeof TYPES)[number] +const TYPE_LABEL: Record = { all: 'Todo', fetch: 'Fetch/XHR', js: 'JS', css: 'CSS', img: 'Img', doc: 'Doc', other: 'Otro' } + export function NetworkPanel({ net }: { net: NetworkView }): JSX.Element { const { records, stats, throughput, clear } = net const [selectedId, setSelectedId] = useState(null) + const [search, setSearch] = useState('') + const [type, setType] = useState('all') const parent = useRef(null) + const q = search.toLowerCase() + const filtered = records.filter( + (r) => matchType(r.type, type) && (q === '' || r.url.toLowerCase().includes(q)) + ) + const rows = useVirtualizer({ - count: records.length, + count: filtered.length, getScrollElement: () => parent.current, estimateSize: () => 26, overscan: 12 @@ -39,6 +50,24 @@ export function NetworkPanel({ net }: { net: NetworkView }): JSX.Element { + {/* filtros: búsqueda + tipo */} +
+
+ + setSearch(e.target.value)} spellCheck={false} placeholder="filtrar por URL" + style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: '#cfd3d9', fontFamily: 'var(--font-mono)', fontSize: 11, userSelect: 'text' }} /> + {search && ( + + )} +
+ +
+ {/* header tabla */}
MTD @@ -48,11 +77,16 @@ export function NetworkPanel({ net }: { net: NetworkView }): JSX.Element { TIME
- {/* filas virtualizadas */} + {/* filas virtualizadas (sobre el conjunto filtrado) */}
+ {filtered.length === 0 && ( +
+ {records.length === 0 ? 'Sin requests aún.' : 'Ningún request coincide con el filtro.'} +
+ )}
{rows.getVirtualItems().map((vi) => ( - setSelectedId(records[vi.index].requestId)} /> + setSelectedId(filtered[vi.index].requestId)} /> ))}
@@ -91,6 +125,21 @@ function nameOf(url: string): string { } } +const TYPE_GROUPS: Record, string[]> = { + fetch: ['XHR', 'Fetch', 'EventSource'], + js: ['Script'], + css: ['Stylesheet'], + img: ['Image'], + doc: ['Document'] +} + +function matchType(recType: string | undefined, filter: TypeFilter): boolean { + if (filter === 'all') return true + const t = recType ?? 'Other' + if (filter === 'other') return !Object.values(TYPE_GROUPS).some((g) => g.includes(t)) + return TYPE_GROUPS[filter].includes(t) +} + function methodColor(m: string): string { if (m === 'POST' || m === 'PUT' || m === 'PATCH') return 'oklch(0.82 0.15 150)' if (m === 'DELETE') return 'oklch(0.7 0.19 25)' From b277cd77f6c2f66938b8434d6f57376d171f0a5b Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:18:29 -0600 Subject: [PATCH 2/6] feat(overlay): resizable panel via top-left corner handle --- src/main/window.ts | 23 ++++++++++++++++++++--- src/preload/index.ts | 3 +++ src/renderer/overlay/Overlay.tsx | 17 ++++++++++++----- src/shared/events.ts | 2 ++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/window.ts b/src/main/window.ts index fa7f443..7808ef2 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -20,10 +20,14 @@ import { IPC, type NavAction, type NavState, type OverlayControl, type ResponseB const CHROME_H = 92 // tab strip (40) + toolbar (52) const MARGIN = 26 -const OVERLAY_EXPANDED = { w: 452, h: 560 } const OVERLAY_COLLAPSED = { w: 232, h: 44 } +const OVERLAY_MIN = { w: 340, h: 320 } +const OVERLAY_MAX = { w: 900, h: 1200 } const START_URL = 'https://example.com' +// Tamaño expandido — mutable (redimensionable por el usuario, REQ v1). +let overlaySize = { w: 452, h: 560 } + let win: BaseWindow let chromeView: WebContentsView let pageView: WebContentsView @@ -53,7 +57,7 @@ function layout(): void { chromeView.setBounds({ x: 0, y: 0, width, height: CHROME_H }) pageView.setBounds({ x: 0, y: CHROME_H, width, height: height - CHROME_H }) - const o = overlayCollapsed ? OVERLAY_COLLAPSED : OVERLAY_EXPANDED + const o = overlayCollapsed ? OVERLAY_COLLAPSED : overlaySize const pos = overlayPos ?? cornerPos(width, height, o) overlayView.setBounds({ x: clamp(pos.x, 0, width - o.w), @@ -171,7 +175,7 @@ function registerIpc(): void { // Arrastre del overlay: acumula deltas de pantalla sobre la posición actual. ipcMain.on(IPC.overlayMove, (_e, dx: number, dy: number) => { const { width, height } = win.getContentBounds() - const o = overlayCollapsed ? OVERLAY_COLLAPSED : OVERLAY_EXPANDED + const o = overlayCollapsed ? OVERLAY_COLLAPSED : overlaySize const cur = overlayPos ?? cornerPos(width, height, o) overlayPos = { x: clamp(cur.x + dx, 0, width - o.w), @@ -179,6 +183,19 @@ function registerIpc(): void { } layout() }) + + // Resize desde la esquina superior-izquierda: crece hacia arriba/izquierda + // manteniendo fija la esquina inferior-derecha (anclaje natural del panel). + ipcMain.on(IPC.overlayResize, (_e, dx: number, dy: number) => { + if (overlayCollapsed) return + const { width, height } = win.getContentBounds() + const cur = overlayPos ?? cornerPos(width, height, overlaySize) + const newW = clamp(overlaySize.w - dx, OVERLAY_MIN.w, Math.min(OVERLAY_MAX.w, width)) + const newH = clamp(overlaySize.h - dy, OVERLAY_MIN.h, Math.min(OVERLAY_MAX.h, height - CHROME_H)) + overlayPos = { x: cur.x + (overlaySize.w - newW), y: cur.y + (overlaySize.h - newH) } + overlaySize = { w: newW, h: newH } + layout() + }) } export function focusPage(): WebContents { diff --git a/src/preload/index.ts b/src/preload/index.ts index 0312267..6f147f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,9 @@ const api = { overlayMove(dx: number, dy: number): void { ipcRenderer.send(IPC.overlayMove, dx, dy) }, + overlayResize(dx: number, dy: number): void { + ipcRenderer.send(IPC.overlayResize, dx, dy) + }, getResponseBody(requestId: string): Promise { return ipcRenderer.invoke(IPC.getResponseBody, requestId) }, diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index 61cdff7..0ac3a15 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -12,9 +12,9 @@ type Tab = 'Network' | 'Console' | 'Memory' | 'CPU' | 'Storage' const TABS: Tab[] = ['Network', 'Console', 'Memory', 'CPU', 'Storage'] const ENABLED: Tab[] = ['Network', 'Console', 'Memory', 'CPU'] // v1 en progreso -// Arrastre del overlay: captura el puntero y manda deltas de pantalla al main, -// que reposiciona la vista. `moved` distingue drag de click (para el pill). -function useDrag(): { +// Gesto de arrastre genérico: captura el puntero y manda deltas de pantalla al +// callback (mover o redimensionar). `moved` distingue drag de click (para el pill). +function useDrag(onDelta: (dx: number, dy: number) => void): { handlers: { onPointerDown: (e: ReactPointerEvent) => void onPointerMove: (e: ReactPointerEvent) => void @@ -39,7 +39,7 @@ function useDrag(): { const dy = e.screenY - d.y if (dx || dy) { if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved.current = true - window.overrun.overlayMove(dx, dy) + onDelta(dx, dy) d.x = e.screenX d.y = e.screenY } @@ -58,7 +58,8 @@ function useDrag(): { export function Overlay(): JSX.Element { const [collapsed, setCollapsed] = useState(false) const [tab, setTab] = useState('Network') - const drag = useDrag() + const drag = useDrag((dx, dy) => window.overrun.overlayMove(dx, dy)) + const resize = useDrag((dx, dy) => window.overrun.overlayResize(dx, dy)) // Hooks montados SIEMPRE (aun colapsado) → los dominios se recolectan sin // importar el tab activo ni el estado del overlay. const net = useNetwork() @@ -72,6 +73,12 @@ export function Overlay(): JSX.Element { return (
+ {/* handle de resize — esquina superior-izquierda */} +
+ +
+ {/* header — zona de arrastre */}
diff --git a/src/shared/events.ts b/src/shared/events.ts index 9e0a000..1022092 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -175,6 +175,8 @@ export const IPC = { overlayState: 'overrun:overlay-state', /** overlay → main: arrastrar el panel (delta en px de pantalla). */ overlayMove: 'overrun:overlay-move', + /** overlay → main: redimensionar el panel desde la esquina superior-izquierda. */ + overlayResize: 'overrun:overlay-resize', /** overlay → main (invoke): pide el body de una respuesta on-demand. */ getResponseBody: 'overrun:get-response-body' } as const From 09b5acb3470b91eba57337838a5fa97eea7775bc Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:37:10 -0600 Subject: [PATCH 3/6] style(overlay): thin translucent scrollbars matching the palette --- src/renderer/shared/tokens.css | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/renderer/shared/tokens.css b/src/renderer/shared/tokens.css index 4d8e679..cfe9062 100644 --- a/src/renderer/shared/tokens.css +++ b/src/renderer/shared/tokens.css @@ -34,3 +34,29 @@ body { -webkit-font-smoothing: antialiased; user-select: none; } + +/* Scrollbars: delgados, translúcidos y en paleta (no el blanco por defecto). */ +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklab, var(--dim) 32%, transparent) transparent; +} +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--dim) 28%, transparent); + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--cyan) 45%, transparent); + background-clip: padding-box; +} +::-webkit-scrollbar-corner { + background: transparent; +} From c4fa69a8d2589aeca2dc03260524cc90e4419ddd Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:44:17 -0600 Subject: [PATCH 4/6] feat(chrome): multi-tab browsing, bookmarks, and frameless native titlebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-tab: cada pestaña es su propio WebContentsView; solo la activa monta y adjunta CDP (el normalizador de red es un stream único). - attachCdp devuelve dispose() para teardown limpio al cambiar de pestaña. - Bookmarks con barra retractil (persistencia in-memory en bookmarks.ts). - Titlebar frameless propia con controles nativos vistos como overlay; tab strip = zona de arrastre (WebkitAppRegion, augmentado en css.d.ts). - Silencia el warning de CSP de Electron en dev (paginas externas ajenas). --- src/main/bookmarks.ts | 89 +++++++++++ src/main/cdp/attach.ts | 36 ++++- src/main/index.ts | 6 +- src/main/window.ts | 271 ++++++++++++++++++++++++++++----- src/preload/index.ts | 46 +++++- src/renderer/chrome/Chrome.tsx | 97 ++++++++++-- src/renderer/css.d.ts | 9 ++ src/shared/events.ts | 53 ++++++- 8 files changed, 546 insertions(+), 61 deletions(-) create mode 100644 src/main/bookmarks.ts create mode 100644 src/renderer/css.d.ts diff --git a/src/main/bookmarks.ts b/src/main/bookmarks.ts new file mode 100644 index 0000000..b069eef --- /dev/null +++ b/src/main/bookmarks.ts @@ -0,0 +1,89 @@ +import { app } from 'electron' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { Bookmark } from '../shared/events' + +// ============================================================================ +// Store de bookmarks — persistido como JSON en la carpeta de datos de la app +// (userData), así sobreviven reinicios. La barra retráctil guarda su estado acá +// mismo para reabrir la app como se dejó. +// ============================================================================ + +interface Store { + items: Bookmark[] + barVisible: boolean +} + +const file = (): string => join(app.getPath('userData'), 'bookmarks.json') +let cache: Store | null = null +let seq = 0 + +function load(): Store { + if (cache) return cache + try { + const parsed = JSON.parse(readFileSync(file(), 'utf8')) as Partial + cache = { + items: Array.isArray(parsed.items) ? parsed.items : [], + barVisible: parsed.barVisible === true + } + } catch { + // Primer arranque o archivo inválido: se empieza vacío. + cache = { items: [], barVisible: false } + } + return cache +} + +function persist(): void { + try { + writeFileSync(file(), JSON.stringify(load(), null, 2)) + } catch (err) { + console.error('[bookmarks] no se pudo guardar:', err) + } +} + +// Compara URLs ignorando la barra final: guardar example.com y example.com/ es lo mismo. +const norm = (url: string): string => url.replace(/\/+$/, '') + +function makeId(): string { + return `bm-${Date.now().toString(36)}-${(seq++).toString(36)}` +} + +export function listBookmarks(): Bookmark[] { + return load().items +} + +export function isBarVisible(): boolean { + return load().barVisible +} + +export function isSaved(url: string): boolean { + return load().items.some((b) => norm(b.url) === norm(url)) +} + +/** Guarda la URL si no estaba; la quita si ya estaba. Devuelve el nuevo estado. */ +export function toggleBookmark(url: string, title: string): boolean { + const s = load() + const i = s.items.findIndex((b) => norm(b.url) === norm(url)) + if (i >= 0) { + s.items.splice(i, 1) + persist() + return false + } + s.items.push({ id: makeId(), url, title: title || url }) + persist() + return true +} + +export function removeBookmark(id: string): void { + const s = load() + s.items = s.items.filter((b) => b.id !== id) + persist() +} + +/** Alterna la barra retráctil y devuelve si quedó visible. */ +export function toggleBar(): boolean { + const s = load() + s.barVisible = !s.barVisible + persist() + return s.barVisible +} diff --git a/src/main/cdp/attach.ts b/src/main/cdp/attach.ts index 4aece1f..f1766aa 100644 --- a/src/main/cdp/attach.ts +++ b/src/main/cdp/attach.ts @@ -14,32 +14,37 @@ import { createPerformancePoller } from '../events/performance' const PROTOCOL_VERSION = '1.3' -export function attachCdp(page: WebContents): void { +// Adjunta CDP a una página y devuelve un `dispose` que revierte todo (detach del +// debugger, pollers y listeners). Solo la pestaña ACTIVA está adjunta a la vez: el +// normalizador de red es un stream único, así que dos tabs adjuntas se pisarían. +export function attachCdp(page: WebContents): () => void { const dbg = page.debugger try { if (!dbg.isAttached()) dbg.attach(PROTOCOL_VERSION) } catch (err) { console.error('[cdp] no se pudo adjuntar el debugger:', err) - return + return () => {} } // Rutea cada mensaje CDP al normalizador de su dominio. - dbg.on('message', (_event, method, params) => { + const onMessage = (_event: unknown, method: string, params: unknown): void => { if (method.startsWith('Network.')) handleCdpNetwork(method, params) else if (method === 'Runtime.consoleAPICalled' || method === 'Runtime.exceptionThrown' || method === 'Log.entryAdded') { handleCdpConsole(method, params) } // Próximas fases: HeapProfiler/Memory → memory, Performance → performance, etc. - }) + } + dbg.on('message', onMessage) const memory = createMemoryPoller(dbg) const performance = createPerformancePoller(dbg) - dbg.on('detach', (_event, reason) => { + const onDetach = (_event: unknown, reason: string): void => { console.warn('[cdp] debugger detached:', reason) memory.stop() performance.stop() - }) + } + dbg.on('detach', onDetach) // Dominios habilitados: Network (D-015) + Runtime/Log (REQ-021) + Performance (REQ-023). dbg.sendCommand('Network.enable').catch((e) => console.error('[cdp] Network.enable', e)) @@ -52,7 +57,22 @@ export function attachCdp(page: WebContents): void { performance.start() // Al navegar a otra página, limpiamos el estado de red acumulado. - page.on('did-start-navigation', (_e, _url, isInPlace, isMainFrame) => { + const onNavigation = (_e: unknown, _url: string, isInPlace: boolean, isMainFrame: boolean): void => { if (isMainFrame && !isInPlace) resetNetwork() - }) + } + page.on('did-start-navigation', onNavigation) + + return () => { + memory.stop() + performance.stop() + dbg.off('message', onMessage) + dbg.off('detach', onDetach) + if (!page.isDestroyed()) page.off('did-start-navigation', onNavigation) + try { + if (dbg.isAttached()) dbg.detach() + } catch { + // El webContents pudo destruirse antes; detach ya no aplica. + } + resetNetwork() + } } diff --git a/src/main/index.ts b/src/main/index.ts index ac25e76..aaba7cd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,11 @@ import { app, BaseWindow, Menu } from 'electron' -import { electronApp } from '@electron-toolkit/utils' +import { electronApp, is } from '@electron-toolkit/utils' import { createWindow } from './window' +// El aviso de CSP inseguro de Electron es ruido en dev: aplica a cada página +// externa que se inspecciona (no controlamos su CSP) y no aparece empaquetado. +if (is.dev) process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' + // Sin barra de menú nativa (File/Edit/View…). Overrun usa su propio chrome. Menu.setApplicationMenu(null) diff --git a/src/main/window.ts b/src/main/window.ts index 7808ef2..be72864 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -3,35 +3,71 @@ import { BaseWindow, WebContentsView, ipcMain, type WebContents } from 'electron import { is } from '@electron-toolkit/utils' import { attachCdp } from './cdp/attach' import { bus } from './events/bus' -import { IPC, type NavAction, type NavState, type OverlayControl, type ResponseBody } from '../shared/events' +import { + IPC, + type NavAction, + type NavState, + type OverlayControl, + type ResponseBody, + type TabsState, + type BookmarksState +} from '../shared/events' +import { + listBookmarks, + isBarVisible, + isSaved, + toggleBookmark, + removeBookmark, + toggleBar +} from './bookmarks' // ============================================================================ // Ventana Overrun — arquitectura de dos superficies apiladas (D-003 / REQ-010,014): // // BaseWindow -// ├── chromeView (WebContentsView) → barra + tabs (React), franja superior -// ├── pageView (WebContentsView) → la app inspeccionada, SIEMPRE full-size -// └── overlayView (WebContentsView) → panel flotante en esquina, SIEMPRE arriba +// ├── pageView[activa] (WebContentsView) → la app inspeccionada, SIEMPRE full-size +// ├── chromeView (WebContentsView) → barra + tabs (React), franja superior +// └── overlayView (WebContentsView) → panel flotante en esquina, SIEMPRE arriba +// +// Multi-tab: cada pestaña es su propio WebContentsView (historial/DOM propios). Solo +// la ACTIVA está montada en el árbol de vistas y adjunta a CDP (el normalizador de +// red es un stream único). Las de fondo siguen vivas y cargando, solo que ocultas. // // El overlay NO forma parte del viewport de la página: flota encima, no la -// redimensiona (REQ-014). La página ocupa todo el alto bajo la barra de chrome -// (una toolbar de navegador no es "viewport de la página", como en cualquier browser). +// redimensiona (REQ-014). La página ocupa todo el alto bajo la barra de chrome. // ============================================================================ -const CHROME_H = 92 // tab strip (40) + toolbar (52) +const TAB_STRIP_H = 40 +const TOOLBAR_H = 52 +const BOOKMARKS_BAR_H = 40 +const CHROME_BASE = TAB_STRIP_H + TOOLBAR_H // 92 const MARGIN = 26 const OVERLAY_COLLAPSED = { w: 232, h: 44 } const OVERLAY_MIN = { w: 340, h: 320 } const OVERLAY_MAX = { w: 900, h: 1200 } const START_URL = 'https://example.com' +// Alto del chrome: crece cuando la barra de bookmarks está desplegada (retráctil). +function chromeHeight(): number { + return CHROME_BASE + (isBarVisible() ? BOOKMARKS_BAR_H : 0) +} + // Tamaño expandido — mutable (redimensionable por el usuario, REQ v1). let overlaySize = { w: 452, h: 560 } +interface Tab { + id: string + view: WebContentsView + // Teardown de CDP mientras la pestaña es la activa; null cuando está de fondo. + disposeCdp: (() => void) | null +} + let win: BaseWindow let chromeView: WebContentsView -let pageView: WebContentsView let overlayView: WebContentsView +let tabs: Tab[] = [] +let activeId = '' +let tabSeq = 0 let overlayCollapsed = false // Posición del overlay: null = anclado a la esquina (default, responsive); // una vez arrastrado, queda fijo en {x,y} (top-left). @@ -39,6 +75,18 @@ let overlayPos: { x: number; y: number } | null = null const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), Math.max(lo, hi)) +function activeTab(): Tab | undefined { + return tabs.find((t) => t.id === activeId) +} + +function hostOf(url: string): string { + try { + return new URL(url).host || url + } catch { + return url + } +} + function loadEntry(view: WebContentsView, entry: 'chrome' | 'overlay'): void { if (is.dev && process.env['ELECTRON_RENDERER_URL']) { view.webContents.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/${entry}/index.html`) @@ -54,22 +102,26 @@ function cornerPos(width: number, height: number, o: { w: number; h: number }): function layout(): void { const { width, height } = win.getContentBounds() - chromeView.setBounds({ x: 0, y: 0, width, height: CHROME_H }) - pageView.setBounds({ x: 0, y: CHROME_H, width, height: height - CHROME_H }) + const ch = chromeHeight() + chromeView.setBounds({ x: 0, y: 0, width, height: ch }) + const at = activeTab() + if (at) at.view.setBounds({ x: 0, y: ch, width, height: height - ch }) const o = overlayCollapsed ? OVERLAY_COLLAPSED : overlaySize const pos = overlayPos ?? cornerPos(width, height, o) overlayView.setBounds({ x: clamp(pos.x, 0, width - o.w), - y: clamp(pos.y, CHROME_H, height - o.h), + y: clamp(pos.y, ch, height - o.h), width: o.w, height: o.h }) } function sendNavState(): void { - const wc = pageView.webContents - const b = pageView.getBounds() + const at = activeTab() + if (!at) return + const wc = at.view.webContents + const b = at.view.getBounds() const state: NavState = { url: wc.getURL(), canGoBack: wc.navigationHistory.canGoBack(), @@ -80,6 +132,120 @@ function sendNavState(): void { chromeView.webContents.send(IPC.navState, state) } +function sendTabs(): void { + const state: TabsState = { + tabs: tabs.map((t) => { + const wc = t.view.webContents + const url = wc.getURL() + return { + id: t.id, + title: wc.getTitle() || hostOf(url) || 'nueva pestaña', + url, + loading: wc.isLoading() + } + }), + activeId + } + chromeView.webContents.send(IPC.tabsState, state) +} + +function sendBookmarks(): void { + const url = activeTab()?.view.webContents.getURL() ?? '' + const state: BookmarksState = { + items: listBookmarks(), + barVisible: isBarVisible(), + currentSaved: isSaved(url) + } + chromeView.webContents.send(IPC.bookmarksState, state) +} + +// Listeners de navegación de una pestaña: refrescan la tira de tabs y, si es la +// activa, el estado de navegación y de bookmarks (la estrella depende de la URL). +function wireTabEvents(tab: Tab): void { + const wc = tab.view.webContents + const update = (): void => { + sendTabs() + if (tab.id === activeId) { + sendNavState() + sendBookmarks() + } + } + wc.on('page-title-updated', update) + wc.on('did-navigate', update) + wc.on('did-navigate-in-page', update) + wc.on('did-start-loading', update) + wc.on('did-stop-loading', update) +} + +function normUrl(url: string): string { + return /^(https?|about|file):/i.test(url) ? url : `https://${url}` +} + +function createTab(url: string = START_URL, activate = true): void { + const view = new WebContentsView({ + webPreferences: { sandbox: true, contextIsolation: true } // sin preload: web no confiable + }) + const tab: Tab = { id: `tab-${(tabSeq++).toString(36)}`, view, disposeCdp: null } + tabs.push(tab) + wireTabEvents(tab) + view.webContents.loadURL(normUrl(url)).catch((err) => console.error('[nav]', err)) + if (activate) activateTab(tab.id) + else sendTabs() +} + +function activateTab(id: string): void { + if (id === activeId) return // clic en la pestaña ya activa: nada que remontar + const next = tabs.find((t) => t.id === id) + if (!next) return + + const prev = activeTab() + if (prev && prev.id !== id) { + if (prev.disposeCdp) { + prev.disposeCdp() + prev.disposeCdp = null + } + win.contentView.removeChildView(prev.view) + } + + activeId = id + // Índice 0 = fondo del z-order: la página queda bajo chrome y overlay. + win.contentView.addChildView(next.view, 0) + if (!next.disposeCdp) next.disposeCdp = attachCdp(next.view.webContents) + + layout() + sendTabs() + sendNavState() + sendBookmarks() +} + +function closeTab(id: string): void { + const idx = tabs.findIndex((t) => t.id === id) + if (idx < 0) return + const tab = tabs[idx] + const wasActive = tab.id === activeId + + if (tab.disposeCdp) { + tab.disposeCdp() + tab.disposeCdp = null + } + if (wasActive) win.contentView.removeChildView(tab.view) + tabs.splice(idx, 1) + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close() + + // Se mantiene siempre ≥1 pestaña: cerrar la última abre una en blanco. + if (tabs.length === 0) { + createTab(START_URL) + return + } + if (wasActive) { + const neighbor = tabs[Math.min(idx, tabs.length - 1)] + activeId = '' + activateTab(neighbor.id) + } else { + sendTabs() + } +} + export function createWindow(): void { win = new BaseWindow({ width: 1440, @@ -87,63 +253,61 @@ export function createWindow(): void { minWidth: 900, minHeight: 600, backgroundColor: '#0a0b0d', // Void (BRANDING) - title: 'Overrun' + title: 'Overrun', + // Sin barra de título nativa: el chrome propio llega hasta el borde superior + // (BRANDING). Los controles nativos min/max/cerrar se dibujan como overlay + // sobre el tab strip (alto 40); ese strip es la zona de arrastre de la ventana. + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#0a0b0d', symbolColor: '#cfd3d9', height: TAB_STRIP_H } }) const uiPrefs = { preload: join(__dirname, '../preload/index.js'), sandbox: true, contextIsolation: true } - // Orden de addChildView = z-order. Página abajo, chrome, overlay arriba de todo. - pageView = new WebContentsView({ - webPreferences: { sandbox: true, contextIsolation: true } // sin preload: web no confiable - }) + // Orden de addChildView = z-order. La página activa se inserta luego en índice 0 + // (fondo); chrome y overlay quedan siempre por encima. chromeView = new WebContentsView({ webPreferences: uiPrefs }) overlayView = new WebContentsView({ webPreferences: uiPrefs }) overlayView.setBackgroundColor('#00000000') // transparente donde el panel no pinta - win.contentView.addChildView(pageView) win.contentView.addChildView(chromeView) win.contentView.addChildView(overlayView) loadEntry(chromeView, 'chrome') loadEntry(overlayView, 'overlay') - pageView.webContents.loadURL(START_URL) // Sincroniza estado colapsado con el overlay cuando termina de cargar. overlayView.webContents.on('did-finish-load', () => overlayView.webContents.send(IPC.overlayState, overlayCollapsed) ) - // Manda estado inicial (url + resolución) al chrome cuando carga. - chromeView.webContents.on('did-finish-load', sendNavState) - - // CDP sobre la PÁGINA (D-002). El overlay se alimenta del bus, no de CDP directo. - attachCdp(pageView.webContents) + // Manda el estado inicial (tabs, url, bookmarks) al chrome cuando carga. + chromeView.webContents.on('did-finish-load', () => { + sendTabs() + sendNavState() + sendBookmarks() + }) // Bus → overlay (IPC). Único puente main→UI de observabilidad. bus.onEvent((evt) => overlayView.webContents.send(IPC.event, evt)) - // Estado de navegación → chrome. - const wc = pageView.webContents - wc.on('did-navigate', sendNavState) - wc.on('did-navigate-in-page', sendNavState) - wc.on('did-start-loading', sendNavState) - wc.on('did-stop-loading', sendNavState) - win.on('resize', () => { layout() sendNavState() // la resolución cambió }) + + // Primera pestaña (adjunta CDP sobre la página, D-002). + createTab(START_URL) layout() registerIpc() } function registerIpc(): void { ipcMain.on(IPC.navigate, (_e, url: string) => { - const target = /^https?:\/\//i.test(url) ? url : `https://${url}` - pageView.webContents.loadURL(target).catch((err) => console.error('[nav]', err)) + activeTab()?.view.webContents.loadURL(normUrl(url)).catch((err) => console.error('[nav]', err)) }) ipcMain.on(IPC.navAction, (_e, action: NavAction) => { - const wc = pageView.webContents + const wc = activeTab()?.view.webContents + if (!wc) return const h = wc.navigationHistory if (action === 'back' && h.canGoBack()) h.goBack() else if (action === 'forward' && h.canGoForward()) h.goForward() @@ -151,6 +315,31 @@ function registerIpc(): void { else if (action === 'stop') wc.stop() }) + // ---- pestañas ---- + ipcMain.on(IPC.tabNew, () => createTab(START_URL)) + ipcMain.on(IPC.tabClose, (_e, id: string) => closeTab(id)) + ipcMain.on(IPC.tabActivate, (_e, id: string) => activateTab(id)) + + // ---- bookmarks ---- + ipcMain.on(IPC.bookmarkToggle, () => { + const wc = activeTab()?.view.webContents + if (!wc) return + toggleBookmark(wc.getURL(), wc.getTitle()) + sendBookmarks() + }) + ipcMain.on(IPC.bookmarkOpen, (_e, url: string) => { + activeTab()?.view.webContents.loadURL(normUrl(url)).catch((err) => console.error('[nav]', err)) + }) + ipcMain.on(IPC.bookmarkRemove, (_e, id: string) => { + removeBookmark(id) + sendBookmarks() + }) + ipcMain.on(IPC.bookmarksBarToggle, () => { + toggleBar() + layout() // el alto del chrome cambió + sendBookmarks() + }) + ipcMain.on(IPC.overlayControl, (_e, control: OverlayControl) => { if (control === 'collapse') overlayCollapsed = true else if (control === 'expand') overlayCollapsed = false @@ -162,8 +351,10 @@ function registerIpc(): void { // Body de respuesta on-demand (REQ-020). CDP lo retiene hasta navegar. ipcMain.handle(IPC.getResponseBody, async (_e, requestId: string): Promise => { + const wc = activeTab()?.view.webContents + if (!wc) return { body: '— sin pestaña activa —', base64: false } try { - const res = (await pageView.webContents.debugger.sendCommand('Network.getResponseBody', { + const res = (await wc.debugger.sendCommand('Network.getResponseBody', { requestId })) as { body: string; base64Encoded: boolean } return { body: res.body, base64: res.base64Encoded } @@ -179,7 +370,7 @@ function registerIpc(): void { const cur = overlayPos ?? cornerPos(width, height, o) overlayPos = { x: clamp(cur.x + dx, 0, width - o.w), - y: clamp(cur.y + dy, CHROME_H, height - o.h) + y: clamp(cur.y + dy, chromeHeight(), height - o.h) } layout() }) @@ -191,13 +382,13 @@ function registerIpc(): void { const { width, height } = win.getContentBounds() const cur = overlayPos ?? cornerPos(width, height, overlaySize) const newW = clamp(overlaySize.w - dx, OVERLAY_MIN.w, Math.min(OVERLAY_MAX.w, width)) - const newH = clamp(overlaySize.h - dy, OVERLAY_MIN.h, Math.min(OVERLAY_MAX.h, height - CHROME_H)) + const newH = clamp(overlaySize.h - dy, OVERLAY_MIN.h, Math.min(OVERLAY_MAX.h, height - chromeHeight())) overlayPos = { x: cur.x + (overlaySize.w - newW), y: cur.y + (overlaySize.h - newH) } overlaySize = { w: newW, h: newH } layout() }) } -export function focusPage(): WebContents { - return pageView.webContents +export function focusPage(): WebContents | undefined { + return activeTab()?.view.webContents } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6f147f1..183b21e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,14 @@ import { contextBridge, ipcRenderer } from 'electron' -import { IPC, type NavAction, type NavState, type OverlayControl, type OverrunEvent, type ResponseBody } from '../shared/events' +import { + IPC, + type NavAction, + type NavState, + type OverlayControl, + type OverrunEvent, + type ResponseBody, + type TabsState, + type BookmarksState +} from '../shared/events' // ============================================================================ // Preload — expone una API mínima y tipada a los renderers (chrome / overlay). @@ -27,6 +36,41 @@ const api = { return () => ipcRenderer.off(IPC.navState, listener) }, + // ---- chrome: pestañas ---- + tabNew(): void { + ipcRenderer.send(IPC.tabNew) + }, + tabClose(id: string): void { + ipcRenderer.send(IPC.tabClose, id) + }, + tabActivate(id: string): void { + ipcRenderer.send(IPC.tabActivate, id) + }, + onTabsState(fn: (state: TabsState) => void): () => void { + const listener = (_e: unknown, state: TabsState): void => fn(state) + ipcRenderer.on(IPC.tabsState, listener) + return () => ipcRenderer.off(IPC.tabsState, listener) + }, + + // ---- chrome: bookmarks ---- + bookmarkToggle(): void { + ipcRenderer.send(IPC.bookmarkToggle) + }, + bookmarkOpen(url: string): void { + ipcRenderer.send(IPC.bookmarkOpen, url) + }, + bookmarkRemove(id: string): void { + ipcRenderer.send(IPC.bookmarkRemove, id) + }, + bookmarksBarToggle(): void { + ipcRenderer.send(IPC.bookmarksBarToggle) + }, + onBookmarksState(fn: (state: BookmarksState) => void): () => void { + const listener = (_e: unknown, state: BookmarksState): void => fn(state) + ipcRenderer.on(IPC.bookmarksState, listener) + return () => ipcRenderer.off(IPC.bookmarksState, listener) + }, + // ---- overlay: control de estado (colapsar / expandir) (D-017) ---- overlayControl(control: OverlayControl): void { ipcRenderer.send(IPC.overlayControl, control) diff --git a/src/renderer/chrome/Chrome.tsx b/src/renderer/chrome/Chrome.tsx index 53377c4..033c338 100644 --- a/src/renderer/chrome/Chrome.tsx +++ b/src/renderer/chrome/Chrome.tsx @@ -1,33 +1,62 @@ import { useEffect, useState } from 'react' -import type { NavState } from '../../shared/events' +import type { NavState, TabsState, BookmarksState } from '../../shared/events' // Barra + tabs de Overrun (chromeView). Frameless propio (BRANDING), no la UI de Chrome. export function Chrome(): JSX.Element { const [nav, setNav] = useState({ url: '', canGoBack: false, canGoForward: false, loading: false, viewport: { width: 0, height: 0 } }) const [draft, setDraft] = useState('') + const [tabs, setTabs] = useState({ tabs: [], activeId: '' }) + const [marks, setMarks] = useState({ items: [], barVisible: false, currentSaved: false }) useEffect(() => window.overrun.onNavState((s) => { setNav(s) setDraft(s.url) }), []) + useEffect(() => window.overrun.onTabsState(setTabs), []) + useEffect(() => window.overrun.onBookmarksState(setMarks), []) const go = (): void => window.overrun.navigate(draft) return (
- {/* tab strip */} -
-
+ {/* tab strip — zona de arrastre de la ventana (frameless); el padding + derecho reserva el hueco de los controles nativos (overlay). */} +
+
OVERRUN
-
- - - {hostOf(nav.url) || 'nueva pestaña'} - + + {/* pestañas abiertas */} +
+ {tabs.tabs.map((t) => { + const active = t.id === tabs.activeId + return ( +
window.overrun.tabActivate(t.id)} + title={t.url || t.title} + style={{ display: 'flex', alignItems: 'center', gap: 7, height: 30, padding: '0 8px 0 11px', borderRadius: '8px 8px 0 0', cursor: 'pointer', maxWidth: 200, minWidth: 96, background: active ? 'var(--surface-2)' : 'transparent', border: '1px solid', borderColor: active ? 'var(--line-soft)' : 'transparent', borderBottom: 'none' }}> + + + {t.title || 'nueva pestaña'} + + +
+ ) + })} +
@@ -37,6 +66,8 @@ export function Chrome(): JSX.Element { window.overrun.navAction('back')} disabled={!nav.canGoBack} d="M10 3 L5 8 L10 13" /> window.overrun.navAction('forward')} disabled={!nav.canGoForward} d="M6 3 L11 8 L6 13" /> window.overrun.navAction(nav.loading ? 'stop' : 'reload')} d="M13 3 v3 h-3 M13 6 A5.5 5.5 0 1 0 13.5 10" /> + window.overrun.bookmarkToggle()} /> + window.overrun.bookmarksBarToggle()} />
Observability
+ + {/* barra de bookmarks (retráctil) */} + {marks.barVisible && ( +
+ {marks.items.length === 0 ? ( + + Sin bookmarks — guardá la página actual con la estrella. + + ) : ( + marks.items.map((b) => ( +
window.overrun.bookmarkOpen(b.url)} + title={b.url} + style={{ display: 'flex', alignItems: 'center', gap: 6, height: 26, padding: '0 6px 0 10px', borderRadius: 7, flexShrink: 0, maxWidth: 190, cursor: 'pointer', background: 'var(--surface)', border: '1px solid var(--line)' }}> + + {b.title || hostOf(b.url)} + + +
+ )) + )} +
+ )}
) } @@ -70,6 +129,26 @@ function NavBtn({ onClick, disabled, d }: { onClick: () => void; disabled?: bool ) } +// Estrella: llena (cyan) si la URL actual ya está guardada, contorno si no. +function StarBtn({ saved, onClick }: { saved: boolean; onClick: () => void }): JSX.Element { + return ( + + ) +} + +// Alterna la barra de bookmarks (retráctil). +function BarToggle({ active, onClick }: { active: boolean; onClick: () => void }): JSX.Element { + return ( + + ) +} + function hostOf(url: string): string { try { return new URL(url).host diff --git a/src/renderer/css.d.ts b/src/renderer/css.d.ts new file mode 100644 index 0000000..f363ff1 --- /dev/null +++ b/src/renderer/css.d.ts @@ -0,0 +1,9 @@ +import 'csstype' + +declare module 'csstype' { + // Zona de arrastre de la ventana frameless (Electron): no está en el tipo base + // de csstype, así que se agrega acá para usarla en estilos inline. + interface Properties { + WebkitAppRegion?: 'drag' | 'no-drag' + } +} diff --git a/src/shared/events.ts b/src/shared/events.ts index 1022092..3c4f7d9 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -178,7 +178,29 @@ export const IPC = { /** overlay → main: redimensionar el panel desde la esquina superior-izquierda. */ overlayResize: 'overrun:overlay-resize', /** overlay → main (invoke): pide el body de una respuesta on-demand. */ - getResponseBody: 'overrun:get-response-body' + getResponseBody: 'overrun:get-response-body', + + // ---- pestañas (multi-tab) ---- + /** main → chrome: lista de pestañas + cuál está activa. */ + tabsState: 'overrun:tabs-state', + /** chrome → main: abrir una pestaña nueva. */ + tabNew: 'overrun:tab-new', + /** chrome → main: cerrar una pestaña por id. */ + tabClose: 'overrun:tab-close', + /** chrome → main: activar una pestaña por id. */ + tabActivate: 'overrun:tab-activate', + + // ---- bookmarks ---- + /** main → chrome: estado de bookmarks (lista, barra visible, url actual guardada). */ + bookmarksState: 'overrun:bookmarks-state', + /** chrome → main: guardar/quitar la URL de la pestaña activa. */ + bookmarkToggle: 'overrun:bookmark-toggle', + /** chrome → main: abrir un bookmark (navega la pestaña activa). */ + bookmarkOpen: 'overrun:bookmark-open', + /** chrome → main: quitar un bookmark por id. */ + bookmarkRemove: 'overrun:bookmark-remove', + /** chrome → main: mostrar/ocultar la barra de bookmarks (retráctil). */ + bookmarksBarToggle: 'overrun:bookmarks-bar-toggle' } as const export interface ResponseBody { @@ -186,6 +208,33 @@ export interface ResponseBody { base64: boolean } +/** Una pestaña abierta, como la ve el chrome (el WebContentsView vive en el main). */ +export interface TabInfo { + id: string + title: string + url: string + loading: boolean +} + +export interface TabsState { + tabs: TabInfo[] + activeId: string +} + +export interface Bookmark { + id: string + url: string + title: string +} + +export interface BookmarksState { + items: Bookmark[] + /** barra de bookmarks desplegada (retráctil). */ + barVisible: boolean + /** la URL de la pestaña activa ya está guardada (estrella llena). */ + currentSaved: boolean +} + export interface Viewport { width: number height: number @@ -196,7 +245,7 @@ export interface NavState { canGoBack: boolean canGoForward: boolean loading: boolean - /** resolución real del viewport de la página (pageView). */ + /** resolución real del viewport de la página (pestaña activa). */ viewport: Viewport } From e590a5038d0525698062c667c31b7de50acdd509 Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:44:17 -0600 Subject: [PATCH 5/6] docs: add app screenshot asset --- docs/assets/image.png | Bin 0 -> 25600 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/image.png diff --git a/docs/assets/image.png b/docs/assets/image.png new file mode 100644 index 0000000000000000000000000000000000000000..61bf640a977f3a800fbc22494cb58d484b9073c8 GIT binary patch literal 25600 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 I`n_lW1xFK9-2eap literal 0 HcmV?d00001 From 9ad6ee5fade9284b541429c0de4c21e0807261fb Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 17:44:35 -0600 Subject: [PATCH 6/6] fix(console): measure row heights so wrapped log lines don't overlap --- src/renderer/overlay/ConsolePanel.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/overlay/ConsolePanel.tsx b/src/renderer/overlay/ConsolePanel.tsx index d045d5c..e0caf48 100644 --- a/src/renderer/overlay/ConsolePanel.tsx +++ b/src/renderer/overlay/ConsolePanel.tsx @@ -11,6 +11,8 @@ export function ConsolePanel({ con }: { con: ConsoleView }): JSX.Element { const rows = useVirtualizer({ count: records.length, getScrollElement: () => parent.current, + // Estimado inicial; la altura real la mide `measureElement` por fila, porque + // el texto largo envuelve a varias líneas y una fila fija se solaparía. estimateSize: () => 30, overscan: 12 }) @@ -34,7 +36,7 @@ export function ConsolePanel({ con }: { con: ConsoleView }): JSX.Element { )}

{rows.getVirtualItems().map((vi) => ( - + ))}
@@ -42,10 +44,11 @@ export function ConsolePanel({ con }: { con: ConsoleView }): JSX.Element { ) } -function Line({ rec, top }: { rec: ConsoleRecord; top: number }): JSX.Element { +function Line({ rec, top, index, measure }: { rec: ConsoleRecord; top: number; index: number; measure: (node: Element | null) => void }): JSX.Element { const c = levelColor(rec.level) return ( -
+
{rec.text} {rec.url && (