Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/main/history.ts
Original file line number Diff line number Diff line change
@@ -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()
}
34 changes: 34 additions & 0 deletions src/main/session.ts
Original file line number Diff line number Diff line change
@@ -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<SessionData>
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)
}
}
58 changes: 52 additions & 6 deletions src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
type OverlayControl,
type ResponseBody,
type TabsState,
type BookmarksState
type BookmarksState,
type HistoryState
} from '../shared/events'
import {
listBookmarks,
Expand All @@ -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):
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -219,6 +246,8 @@ function activateTab(id: string): void {
sendTabs()
sendNavState()
sendBookmarks()
sendHistory()
storeSession()
}

function closeTab(id: string): void {
Expand Down Expand Up @@ -247,6 +276,7 @@ function closeTab(id: string): void {
} else {
sendTabs()
}
storeSession()
}

export function createWindow(): void {
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
type OverrunEvent,
type ResponseBody,
type TabsState,
type BookmarksState
type BookmarksState,
type HistoryState
} from '../shared/events'

// ============================================================================
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 20 additions & 13 deletions src/renderer/chrome/Chrome.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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 {
const [nav, setNav] = useState<NavState>({ url: '', canGoBack: false, canGoForward: false, loading: false, viewport: { width: 0, height: 0 } })
const [draft, setDraft] = useState('')
const [tabs, setTabs] = useState<TabsState>({ tabs: [], activeId: '' })
const [marks, setMarks] = useState<BookmarksState>({ 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<string[]>([])
// Historial completo persistido (viene del main); alimenta el autocompletado.
const [history, setHistory] = useState<HistoryEntry[]>([])
const [showHist, setShowHist] = useState(false)

useEffect(() => window.overrun.onNavState((s) => {
Expand All @@ -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.
Expand Down Expand Up @@ -103,9 +107,9 @@ export function Chrome(): JSX.Element {
/>
{showHist && suggestions.length > 0 && (
<div style={{ position: 'absolute', top: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 30, background: 'var(--surface)', border: '1px solid var(--line)', borderRadius: 9, overflow: 'hidden', boxShadow: '0 18px 44px -14px rgba(0,0,0,0.7)' }}>
<div style={{ padding: '7px 12px 3px', fontSize: 9, letterSpacing: '0.12em', color: 'var(--mute)', fontFamily: 'var(--font-mono)' }}>RECIENTES</div>
{suggestions.map((u) => (
<HistRow key={u} url={u} onPick={() => navigateTo(u)} />
<div style={{ padding: '7px 12px 3px', fontSize: 9, letterSpacing: '0.12em', color: 'var(--mute)', fontFamily: 'var(--font-mono)' }}>{q === '' ? 'RECIENTES' : 'HISTORIAL'}</div>
{suggestions.map((e) => (
<HistRow key={e.url} entry={e} onPick={() => navigateTo(e.url)} />
))}
</div>
)}
Expand Down Expand Up @@ -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 (
<div
onMouseDown={(e) => { 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' }}>
<svg width="12" height="12" viewBox="0 0 16 16" style={{ flexShrink: 0, color: hover ? 'var(--cyan-bright)' : 'var(--mute)' }}>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.3" fill="none" />
<path d="M8 5 V8 L10 9.5" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: hover ? '#e6e9ee' : '#cfd3d9', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{url}</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 12, color: hover ? '#e6e9ee' : '#cfd3d9', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.title}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--mute)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.url}</div>
</div>
</div>
)
}
Expand Down
18 changes: 17 additions & 1 deletion src/shared/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading