From c9766e90ee156c1c7d7b69aa79a52925fb31eb83 Mon Sep 17 00:00:00 2001 From: ElVatoEste Date: Thu, 20 Aug 2026 18:44:58 -0600 Subject: [PATCH] feat(persistence): persist navigation history and open tabs; full-history autocomplete - history.ts: historial de navegacion persistido en userData (500 entradas), colapsa recargas. - session.ts: persiste las pestanas abiertas (URLs + activa); se restauran al arrancar. - Fix: el chrome reenvia tabs/estado en cada did-finish-load (reload/HMR) -> la tira de tabs no queda vacia. - Autocompletado de la barra contra TODO el historial (url o titulo), no solo el last-5: sitios viejos siguen sugiriendo. - Guarda 'restoring' para no pisar la sesion con lista vacia durante el arranque. --- src/main/history.ts | 56 ++++++++++++++++++++++++++++++++ src/main/session.ts | 34 ++++++++++++++++++++ src/main/window.ts | 58 ++++++++++++++++++++++++++++++---- src/preload/index.ts | 13 +++++++- src/renderer/chrome/Chrome.tsx | 33 +++++++++++-------- src/shared/events.ts | 18 ++++++++++- 6 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 src/main/history.ts create mode 100644 src/main/session.ts diff --git a/src/main/history.ts b/src/main/history.ts new file mode 100644 index 0000000..5da8071 --- /dev/null +++ b/src/main/history.ts @@ -0,0 +1,56 @@ +import { app } from 'electron' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { HistoryEntry } from '../shared/events' + +// ============================================================================ +// Historial de navegación — persistido como JSON en userData (mismo patrón que +// bookmarks). Sobrevive reinicios; alimenta las sugerencias de la barra. +// ============================================================================ + +const MAX = 500 +const file = (): string => join(app.getPath('userData'), 'history.json') +let cache: HistoryEntry[] | null = null + +function load(): HistoryEntry[] { + if (cache) return cache + try { + const parsed = JSON.parse(readFileSync(file(), 'utf8')) + cache = Array.isArray(parsed) ? (parsed as HistoryEntry[]) : [] + } catch { + cache = [] + } + return cache +} + +function persist(): void { + try { + writeFileSync(file(), JSON.stringify(load())) + } catch (err) { + console.error('[history] no se pudo guardar:', err) + } +} + +/** Registra una visita. Colapsa recargas/entradas consecutivas de la misma URL. */ +export function addVisit(url: string, title: string): void { + if (!/^https?:/i.test(url)) return // about:blank y otros no entran al historial + const h = load() + if (h[0]?.url === url) { + if (title) h[0].title = title + h[0].ts = Date.now() + } else { + h.unshift({ url, title: title || url, ts: Date.now() }) + if (h.length > MAX) h.length = MAX + } + persist() +} + +/** Las `n` visitas más recientes (más nueva primero). */ +export function recentVisits(n: number): HistoryEntry[] { + return load().slice(0, n) +} + +export function clearHistory(): void { + cache = [] + persist() +} diff --git a/src/main/session.ts b/src/main/session.ts new file mode 100644 index 0000000..822cc54 --- /dev/null +++ b/src/main/session.ts @@ -0,0 +1,34 @@ +import { app } from 'electron' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// ============================================================================ +// Sesión de ventana — persiste las pestañas abiertas (URLs + cuál activa) en +// userData, para reabrir Overrun tal como se dejó. Mismo patrón JSON que bookmarks. +// ============================================================================ + +export interface SessionData { + tabs: string[] + activeIndex: number +} + +const file = (): string => join(app.getPath('userData'), 'session.json') + +export function loadSession(): SessionData { + try { + const p = JSON.parse(readFileSync(file(), 'utf8')) as Partial + const tabs = Array.isArray(p.tabs) ? p.tabs.filter((u) => typeof u === 'string') : [] + const activeIndex = typeof p.activeIndex === 'number' && p.activeIndex >= 0 && p.activeIndex < tabs.length ? p.activeIndex : 0 + return { tabs, activeIndex } + } catch { + return { tabs: [], activeIndex: 0 } + } +} + +export function saveSession(data: SessionData): void { + try { + writeFileSync(file(), JSON.stringify(data)) + } catch (err) { + console.error('[session] no se pudo guardar:', err) + } +} diff --git a/src/main/window.ts b/src/main/window.ts index 52b8ff6..cba0967 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -10,7 +10,8 @@ import { type OverlayControl, type ResponseBody, type TabsState, - type BookmarksState + type BookmarksState, + type HistoryState } from '../shared/events' import { listBookmarks, @@ -20,6 +21,8 @@ import { removeBookmark, toggleBar } from './bookmarks' +import { addVisit, recentVisits, clearHistory } from './history' +import { loadSession, saveSession } from './session' // ============================================================================ // Ventana Overrun — arquitectura de dos superficies apiladas (D-003 / REQ-010,014): @@ -162,6 +165,24 @@ function sendBookmarks(): void { chromeView.webContents.send(IPC.bookmarksState, state) } +// Historial completo persistido → chrome (para autocompletar contra TODO lo +// visitado, no solo lo reciente: escribir "you" sugiere youtube aunque sea viejo). +function sendHistory(): void { + const state: HistoryState = { items: recentVisits(300) } + chromeView.webContents.send(IPC.historyState, state) +} + +// Persiste las pestañas abiertas (URLs + activa). No guarda durante el arranque +// (antes de restaurar) para no pisar la sesión con una lista vacía. +let restoring = true +function storeSession(): void { + if (restoring) return + saveSession({ + tabs: tabs.map((t) => t.view.webContents.getURL()), + activeIndex: Math.max(0, tabs.findIndex((t) => t.id === activeId)) + }) +} + // 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 { @@ -173,8 +194,13 @@ function wireTabEvents(tab: Tab): void { sendBookmarks() } } - wc.on('page-title-updated', update) - wc.on('did-navigate', update) + const record = (): void => { + addVisit(wc.getURL(), wc.getTitle()) + sendHistory() + storeSession() // la URL de la pestaña cambió + } + wc.on('page-title-updated', () => { update(); addVisit(wc.getURL(), wc.getTitle()); sendHistory() }) + wc.on('did-navigate', () => { update(); record() }) wc.on('did-navigate-in-page', update) wc.on('did-start-loading', update) wc.on('did-stop-loading', update) @@ -194,6 +220,7 @@ function createTab(url: string = START_URL, activate = true): void { view.webContents.loadURL(normUrl(url)).catch((err) => console.error('[nav]', err)) if (activate) activateTab(tab.id) else sendTabs() + storeSession() } function activateTab(id: string): void { @@ -219,6 +246,8 @@ function activateTab(id: string): void { sendTabs() sendNavState() sendBookmarks() + sendHistory() + storeSession() } function closeTab(id: string): void { @@ -247,6 +276,7 @@ function closeTab(id: string): void { } else { sendTabs() } + storeSession() } export function createWindow(): void { @@ -283,11 +313,13 @@ export function createWindow(): void { overlayView.webContents.on('did-finish-load', () => overlayView.webContents.send(IPC.overlayState, overlayCollapsed) ) - // Manda el estado inicial (tabs, url, bookmarks) al chrome cuando carga. + // Reenvía el estado completo al chrome cada vez que carga (incluye HMR/reload en + // dev) → la tira de tabs no queda vacía tras un reload del renderer. chromeView.webContents.on('did-finish-load', () => { sendTabs() sendNavState() sendBookmarks() + sendHistory() }) // Bus → overlay (IPC). Único puente main→UI de observabilidad. @@ -298,13 +330,27 @@ export function createWindow(): void { sendNavState() // la resolución cambió }) - // Primera pestaña (adjunta CDP sobre la página, D-002). - createTab(START_URL) + // Restaura la sesión: reabre las pestañas que estaban abiertas (o una nueva). + const s = loadSession() + if (s.tabs.length > 0) { + s.tabs.forEach((u) => createTab(/^https?:/i.test(u) ? u : START_URL, false)) + const target = tabs[s.activeIndex] ?? tabs[0] + if (target) activateTab(target.id) + } else { + createTab(START_URL) + } + restoring = false // a partir de acá, los cambios sí se persisten + storeSession() layout() registerIpc() } function registerIpc(): void { + ipcMain.on(IPC.historyClear, () => { + clearHistory() + sendHistory() + }) + // Expande/contrae el chrome para popups que deben flotar sobre la página. ipcMain.on(IPC.chromeExpand, (_e, open: boolean) => { chromeExpanded = open diff --git a/src/preload/index.ts b/src/preload/index.ts index 5b571d1..deac4a2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,7 +7,8 @@ import { type OverrunEvent, type ResponseBody, type TabsState, - type BookmarksState + type BookmarksState, + type HistoryState } from '../shared/events' // ============================================================================ @@ -71,6 +72,16 @@ const api = { return () => ipcRenderer.off(IPC.bookmarksState, listener) }, + // ---- chrome: historial ---- + historyClear(): void { + ipcRenderer.send(IPC.historyClear) + }, + onHistoryState(fn: (state: HistoryState) => void): () => void { + const listener = (_e: unknown, state: HistoryState): void => fn(state) + ipcRenderer.on(IPC.historyState, listener) + return () => ipcRenderer.off(IPC.historyState, 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 b15cc80..351c43f 100644 --- a/src/renderer/chrome/Chrome.tsx +++ b/src/renderer/chrome/Chrome.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import type { NavState, TabsState, BookmarksState } from '../../shared/events' +import type { NavState, TabsState, BookmarksState, HistoryEntry } from '../../shared/events' // Barra + tabs de Overrun (chromeView). Frameless propio (BRANDING), no la UI de Chrome. export function Chrome(): JSX.Element { @@ -7,8 +7,8 @@ export function Chrome(): JSX.Element { const [draft, setDraft] = useState('') const [tabs, setTabs] = useState({ tabs: [], activeId: '' }) const [marks, setMarks] = useState({ items: [], barVisible: false, currentSaved: false }) - // Histórico de las últimas 5 búsquedas/URLs (sesión; el chrome no se recarga). - const [history, setHistory] = useState([]) + // Historial completo persistido (viene del main); alimenta el autocompletado. + const [history, setHistory] = useState([]) const [showHist, setShowHist] = useState(false) useEffect(() => window.overrun.onNavState((s) => { @@ -17,17 +17,21 @@ export function Chrome(): JSX.Element { }), []) useEffect(() => window.overrun.onTabsState(setTabs), []) useEffect(() => window.overrun.onBookmarksState(setMarks), []) + useEffect(() => window.overrun.onHistoryState((s) => setHistory(s.items)), []) const navigateTo = (raw: string): void => { const url = raw.trim() if (!url) return - window.overrun.navigate(url) - setHistory((h) => [url, ...h.filter((u) => u !== url)].slice(0, 5)) + window.overrun.navigate(url) // el main registra la visita en el historial persistido setShowHist(false) } const go = (): void => navigateTo(draft) - const suggestions = history.filter((u) => draft.trim() === '' || u.toLowerCase().includes(draft.toLowerCase())) + // Autocompletado contra TODO el historial (url o título), no solo lo reciente. + const q = draft.trim().toLowerCase() + const suggestions = history + .filter((e) => q === '' || e.url.toLowerCase().includes(q) || e.title.toLowerCase().includes(q)) + .slice(0, 8) // El dropdown se recorta por los bounds del chromeView; cuando está visible se // expande la vista del chrome a toda la ventana (transparente) para que flote. @@ -103,9 +107,9 @@ export function Chrome(): JSX.Element { /> {showHist && suggestions.length > 0 && (
-
RECIENTES
- {suggestions.map((u) => ( - navigateTo(u)} /> +
{q === '' ? 'RECIENTES' : 'HISTORIAL'}
+ {suggestions.map((e) => ( + navigateTo(e.url)} /> ))}
)} @@ -182,20 +186,23 @@ function BarToggle({ active, onClick }: { active: boolean; onClick: () => void } ) } -// Fila del historial: hover en paleta; onMouseDown navega antes del blur del input. -function HistRow({ url, onPick }: { url: string; onPick: () => void }): JSX.Element { +// Fila del historial: título + url; hover en paleta; onMouseDown navega antes del blur. +function HistRow({ entry, onPick }: { entry: HistoryEntry; onPick: () => void }): JSX.Element { const [hover, setHover] = useState(false) return (
{ e.preventDefault(); onPick() }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} - style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '7px 12px', cursor: 'pointer', background: hover ? 'oklch(0.82 0.15 195 / 0.1)' : 'transparent' }}> + style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '6px 12px', cursor: 'pointer', background: hover ? 'oklch(0.82 0.15 195 / 0.1)' : 'transparent' }}> - {url} +
+
{entry.title}
+
{entry.url}
+
) } diff --git a/src/shared/events.ts b/src/shared/events.ts index b3d6d0f..1776c88 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -228,7 +228,13 @@ export const IPC = { /** 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' + bookmarksBarToggle: 'overrun:bookmarks-bar-toggle', + + // ---- historial ---- + /** main → chrome: visitas recientes (persistidas) para sugerencias de la barra. */ + historyState: 'overrun:history-state', + /** chrome → main: limpiar el historial persistido. */ + historyClear: 'overrun:history-clear' } as const export interface ResponseBody { @@ -263,6 +269,16 @@ export interface BookmarksState { currentSaved: boolean } +export interface HistoryEntry { + url: string + title: string + ts: number +} + +export interface HistoryState { + items: HistoryEntry[] +} + export interface Viewport { width: number height: number