diff --git a/docs/assets/image.png b/docs/assets/image.png new file mode 100644 index 0000000..61bf640 Binary files /dev/null and b/docs/assets/image.png differ 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 fa7f443..be72864 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -3,31 +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_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' +// 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). @@ -35,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`) @@ -50,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 : 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), - 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(), @@ -76,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, @@ -83,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() @@ -147,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 @@ -158,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 } @@ -171,16 +366,29 @@ 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), - y: clamp(cur.y + dy, CHROME_H, height - o.h) + y: clamp(cur.y + dy, chromeHeight(), height - o.h) } 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 - 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 0312267..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) @@ -34,6 +78,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/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/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 && ( 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)' 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/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; +} diff --git a/src/shared/events.ts b/src/shared/events.ts index 9e0a000..3c4f7d9 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -175,8 +175,32 @@ 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' + 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 { @@ -184,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 @@ -194,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 }