diff --git a/README.md b/README.md index adbfb3f..ee4dd98 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ npm run dev - Last browser session tabs are restored on startup (up to 20 tabs). - Renderer supports a coder-focused orange light/dark theme toggle (persisted per user). - Bookmarks MVP is wired to SQLite: star action opens a name+URL save prompt (prefilled defaults), plus favicon bookmark bar and sidebar open/remove flow. +- History MVP records visits automatically and exposes a sidebar with recent entries and clear action. ## Storage Foundation @@ -113,6 +114,7 @@ Use this gate before cutting a release branch or tag. | Cmd/Ctrl + L | Focus address bar | | Cmd/Ctrl + R | Reload | | Cmd/Ctrl + D | Toggle bookmark for active page | +| Cmd/Ctrl + H | Toggle history sidebar | | Cmd/Ctrl + Shift + B | Toggle bookmark bar | | Cmd/Ctrl + Shift + O | Toggle floating window | diff --git a/src/main/index.ts b/src/main/index.ts index 8135f59..4c1f514 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabSnapshot, TabsStateSnapshot, } from '../shared/ipc'; @@ -27,6 +28,7 @@ const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL; const RENDERER_DIST = path.join(__dirname, '../renderer'); const TABS_SESSION_KEY = 'tabsSession'; const MAX_RESTORED_TABS = 20; +const HISTORY_LIST_LIMIT = 200; interface ManagedTab { id: number; @@ -147,6 +149,22 @@ function getBookmarksSnapshot(): BookmarkSnapshot[] { }); } +function getHistorySnapshot(): HistorySnapshot[] { + if (!storageLayer) { + return []; + } + + return storageLayer.history.listRecent(HISTORY_LIST_LIMIT).map(historyEntry => { + return { + id: historyEntry.id, + url: historyEntry.url, + title: historyEntry.title, + visitCount: historyEntry.visitCount, + lastVisitedAt: historyEntry.lastVisitedAt, + }; + }); +} + function emitBookmarksChanged(): void { if (!mainWindow || mainWindow.isDestroyed()) { return; @@ -155,6 +173,14 @@ function emitBookmarksChanged(): void { mainWindow.webContents.send(IPC_CHANNELS.BOOKMARKS_CHANGED, getBookmarksSnapshot()); } +function emitHistoryChanged(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.webContents.send(IPC_CHANNELS.HISTORY_CHANGED, getHistorySnapshot()); +} + function toggleActiveBookmark(): BookmarkSnapshot[] { const activeTab = getActiveTab(); if (!activeTab?.url || !storageLayer) { @@ -197,6 +223,24 @@ function upsertBookmark(payload: BookmarkUpsertPayload): BookmarkSnapshot[] { return bookmarks; } +function recordHistoryVisit(url: string | null, title: string | null): void { + if (!storageLayer || !url || !isHttpNavigationUrl(url)) { + return; + } + + const historyTitle = title?.trim() || url; + storageLayer.history.recordVisit(url, historyTitle); + emitHistoryChanged(); +} + +function clearHistory(): HistorySnapshot[] { + storageLayer?.history.clear(); + + const history = getHistorySnapshot(); + emitHistoryChanged(); + return history; +} + function persistTabsSession(): void { const activeTabIndex = tabs.findIndex(tab => tab.id === activeTabId); const sessionSnapshot: PersistedTabSession = { @@ -362,11 +406,16 @@ function configureTabEvents(tab: ManagedTab): void { }); webContents.on('did-stop-loading', () => { + const currentUrl = webContents.getURL() || null; + const currentTitle = webContents.getTitle() || null; + syncTabFromContents(webContents.id, entry => { entry.isLoading = false; - entry.url = webContents.getURL() || null; - entry.title = webContents.getTitle() || entry.title; + entry.url = currentUrl; + entry.title = currentTitle || entry.title; }); + + recordHistoryVisit(currentUrl, currentTitle); }); webContents.on('did-navigate', (_event, navigationUrl) => { @@ -590,6 +639,7 @@ function createMainWindow(): void { emitTabsState(); emitBookmarksChanged(); + emitHistoryChanged(); }); mainWindow.on('resize', () => { @@ -778,6 +828,14 @@ ipcMain.handle(IPC_CHANNELS.BOOKMARKS_REMOVE, (_event, payload: unknown) => { return removeBookmarkById(bookmarkId); }); +ipcMain.handle(IPC_CHANNELS.HISTORY_GET, () => { + return getHistorySnapshot(); +}); + +ipcMain.handle(IPC_CHANNELS.HISTORY_CLEAR, () => { + return clearHistory(); +}); + process.on('uncaughtException', (error) => { console.error('[main] uncaughtException', error); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index 850e410..1c12785 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import { parseBookmarkUpsertPayload, parseBrowserBoundsPayload, parseFloatNavigatePayload, + parseHistorySnapshotsPayload, parseTabIdPayload, parseTabNavigatePayload, parseTabsStateSnapshotPayload, @@ -14,6 +15,7 @@ import type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabsStateSnapshot, } from '../shared/ipc-contract'; @@ -167,5 +169,32 @@ contextBridge.exposeInMainWorld('orb', { }; }, + getHistory: async (): Promise => { + const payload = await ipcRenderer.invoke(IPC_CHANNELS.HISTORY_GET); + return parseHistorySnapshotsPayload(payload) ?? []; + }, + + clearHistory: async (): Promise => { + const payload = await ipcRenderer.invoke(IPC_CHANNELS.HISTORY_CLEAR); + return parseHistorySnapshotsPayload(payload) ?? []; + }, + + onHistoryChanged: (callback: (history: HistorySnapshot[]) => void) => { + const handler = (_event: Electron.IpcRendererEvent, payload: unknown): void => { + const parsedHistory = parseHistorySnapshotsPayload(payload); + if (!parsedHistory) { + return; + } + + callback(parsedHistory); + }; + + ipcRenderer.on(IPC_CHANNELS.HISTORY_CHANGED, handler); + + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.HISTORY_CHANGED, handler); + }; + }, + platform: process.platform, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 6846964..10435ee 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -20,6 +20,7 @@ + @@ -46,7 +47,8 @@

Cmd+T new tab  ·  Cmd+W close tab  ·  - Cmd+D bookmark page + Cmd+D bookmark page  ·  + Cmd+H history

@@ -59,6 +61,15 @@

Bookm

    No bookmarks yet. Open a page and press the star.

    + + diff --git a/src/renderer/main.ts b/src/renderer/main.ts index d711253..76a035b 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -2,6 +2,7 @@ import './styles/tailwind.css'; import type { BookmarkSnapshot, BrowserBounds, + HistorySnapshot, TabSnapshot, TabsStateSnapshot, } from '../shared/ipc-contract'; @@ -23,9 +24,11 @@ interface RendererState { tabs: TabSnapshot[]; activeTabId: number | null; bookmarks: BookmarkSnapshot[]; + history: HistorySnapshot[]; isBookmarkBarVisible: boolean; isBookmarkEditorOpen: boolean; isBookmarksSidebarOpen: boolean; + isHistorySidebarOpen: boolean; } const ORB_BOOKMARK_BAR_VISIBLE_KEY = 'orb-bookmark-bar-visible'; @@ -34,9 +37,11 @@ const state: RendererState = { tabs: [], activeTabId: null, bookmarks: [], + history: [], isBookmarkBarVisible: true, isBookmarkEditorOpen: false, isBookmarksSidebarOpen: false, + isHistorySidebarOpen: false, }; const tabsContainer = document.getElementById('tabs') as HTMLDivElement; @@ -51,6 +56,9 @@ const bookmarkEditorCancel = document.getElementById('bookmark-editor-cancel') a const bookmarksSidebar = document.getElementById('bookmarks-sidebar') as HTMLDivElement; const bookmarksList = document.getElementById('bookmarks-list') as HTMLUListElement; const bookmarksEmpty = document.getElementById('bookmarks-empty') as HTMLParagraphElement; +const historySidebar = document.getElementById('history-sidebar') as HTMLDivElement; +const historyList = document.getElementById('history-list') as HTMLUListElement; +const historyEmpty = document.getElementById('history-empty') as HTMLParagraphElement; const browserArea = document.getElementById('browser-area') as HTMLDivElement; const newTabPage = document.getElementById('new-tab-page') as HTMLDivElement; const addressBar = document.getElementById('address-bar') as HTMLInputElement; @@ -62,6 +70,8 @@ const btnTheme = document.getElementById('btn-theme') as HTMLButtonElement; const btnBookmark = document.getElementById('btn-bookmark') as HTMLButtonElement; const btnBookmarkBar = document.getElementById('btn-bookmark-bar') as HTMLButtonElement; const btnBookmarks = document.getElementById('btn-bookmarks') as HTMLButtonElement; +const btnHistory = document.getElementById('btn-history') as HTMLButtonElement; +const btnHistoryClear = document.getElementById('btn-history-clear') as HTMLButtonElement; const btnFloat = document.getElementById('btn-float') as HTMLButtonElement; const btnNewTab = document.getElementById('btn-new-tab') as HTMLButtonElement; const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); @@ -69,6 +79,7 @@ const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); let unsubscribeOpenUrl: (() => void) | null = null; let unsubscribeTabsState: (() => void) | null = null; let unsubscribeBookmarks: (() => void) | null = null; +let unsubscribeHistory: (() => void) | null = null; function getStoredBookmarkBarVisibility(): boolean { try { @@ -217,7 +228,7 @@ function triggerBookmarkAction(): void { openBookmarkEditor(activeTab.title || activeTab.url, activeTab.url); } -function getBookmarkFaviconUrl(rawUrl: string): string { +function getSiteFaviconUrl(rawUrl: string): string { try { const parsedUrl = new URL(rawUrl); return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(parsedUrl.origin)}`; @@ -226,6 +237,15 @@ function getBookmarkFaviconUrl(rawUrl: string): string { } } +function formatHistoryTimestamp(value: string): string { + const parsedDate = new Date(value); + if (Number.isNaN(parsedDate.getTime())) { + return 'Unknown time'; + } + + return parsedDate.toLocaleString(); +} + function syncBrowserBounds(): void { // BrowserView is owned by main, so renderer sends browser area coordinates over IPC. const rect = browserArea.getBoundingClientRect(); @@ -285,6 +305,11 @@ function renderBookmarkControls(): void { btnBookmarks.title = state.isBookmarksSidebarOpen ? 'Hide bookmarks sidebar' : 'Show bookmarks sidebar'; + + btnHistory.textContent = state.isHistorySidebarOpen ? '×' : 'H'; + btnHistory.title = state.isHistorySidebarOpen + ? 'Hide history sidebar (Cmd/Ctrl+H)' + : 'Show history sidebar (Cmd/Ctrl+H)'; } function renderBookmarkEditor(): void { @@ -308,7 +333,7 @@ function renderBookmarkBar(): void { } state.bookmarks.forEach(bookmark => { - const faviconUrl = getBookmarkFaviconUrl(bookmark.url); + const faviconUrl = getSiteFaviconUrl(bookmark.url); const faviconMarkup = faviconUrl ? `` : ''; @@ -355,6 +380,37 @@ function renderBookmarksSidebar(): void { }); } +function renderHistorySidebar(): void { + historySidebar.classList.toggle('hidden', !state.isHistorySidebarOpen); + + historyList.innerHTML = ''; + const hasHistory = state.history.length > 0; + historyEmpty.style.display = hasHistory ? 'none' : 'block'; + btnHistoryClear.disabled = !hasHistory; + + if (!hasHistory) { + return; + } + + state.history.forEach(historyEntry => { + const faviconUrl = getSiteFaviconUrl(historyEntry.url); + const historyElement = document.createElement('li'); + historyElement.className = 'mb-1 last:mb-0'; + historyElement.innerHTML = ` + + `; + + historyList.appendChild(historyElement); + }); +} + function render(): void { renderTabs(); renderNavigation(); @@ -362,6 +418,7 @@ function render(): void { renderBookmarkBar(); renderBookmarkEditor(); renderBookmarksSidebar(); + renderHistorySidebar(); } function applyState(nextState: TabsStateSnapshot): void { @@ -376,7 +433,16 @@ function applyBookmarks(nextBookmarks: BookmarkSnapshot[]): void { render(); } +function applyHistory(nextHistory: HistorySnapshot[]): void { + state.history = nextHistory; + render(); +} + function setBookmarksSidebarOpen(isOpen: boolean): void { + if (isOpen) { + state.isHistorySidebarOpen = false; + } + if (state.isBookmarksSidebarOpen === isOpen) { return; } @@ -390,6 +456,24 @@ function toggleBookmarksSidebar(): void { setBookmarksSidebarOpen(!state.isBookmarksSidebarOpen); } +function setHistorySidebarOpen(isOpen: boolean): void { + if (isOpen) { + state.isBookmarksSidebarOpen = false; + } + + if (state.isHistorySidebarOpen === isOpen) { + return; + } + + state.isHistorySidebarOpen = isOpen; + render(); + syncBrowserBounds(); +} + +function toggleHistorySidebar(): void { + setHistorySidebarOpen(!state.isHistorySidebarOpen); +} + function setBookmarkBarVisible(isVisible: boolean): void { if (state.isBookmarkBarVisible === isVisible) { return; @@ -459,6 +543,14 @@ btnBookmarks.addEventListener('click', () => { toggleBookmarksSidebar(); }); +btnHistory.addEventListener('click', () => { + toggleHistorySidebar(); +}); + +btnHistoryClear.addEventListener('click', () => { + void window.orb.clearHistory().then(applyHistory); +}); + btnFloat.addEventListener('click', () => { void window.orb.toggleFloat(); }); @@ -599,6 +691,30 @@ bookmarkBarList.addEventListener('click', event => { navigate(bookmark.url); }); +historyList.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const openTarget = target.closest('[data-history-open-id]'); + if (!openTarget) { + return; + } + + const historyId = Number(openTarget.getAttribute('data-history-open-id')); + if (!Number.isInteger(historyId) || historyId <= 0) { + return; + } + + const historyEntry = state.history.find(entry => entry.id === historyId); + if (!historyEntry) { + return; + } + + navigate(historyEntry.url); +}); + unsubscribeOpenUrl = window.orb.onOpenUrl(url => { // Float window already triggers main-process navigation; we mirror address text here. addressBar.value = url; @@ -612,6 +728,10 @@ unsubscribeBookmarks = window.orb.onBookmarksChanged(nextBookmarks => { applyBookmarks(nextBookmarks); }); +unsubscribeHistory = window.orb.onHistoryChanged(nextHistory => { + applyHistory(nextHistory); +}); + document.addEventListener('keydown', event => { if (event.key === 'Escape' && state.isBookmarkEditorOpen) { event.preventDefault(); @@ -652,6 +772,12 @@ document.addEventListener('keydown', event => { return; } + if (mod && event.key.toLowerCase() === 'h') { + event.preventDefault(); + toggleHistorySidebar(); + return; + } + if (mod && event.shiftKey && event.key.toLowerCase() === 'b') { event.preventDefault(); toggleBookmarkBar(); @@ -678,12 +804,19 @@ window.addEventListener('beforeunload', () => { unsubscribeBookmarks?.(); unsubscribeBookmarks = null; + + unsubscribeHistory?.(); + unsubscribeHistory = null; }); window.orb.getBookmarks().then(initialBookmarks => { applyBookmarks(initialBookmarks); }); +window.orb.getHistory().then(initialHistory => { + applyHistory(initialHistory); +}); + window.orb.getTabsState().then(initialState => { applyState(initialState); if (initialState.tabs.length === 0) { diff --git a/src/renderer/window.d.ts b/src/renderer/window.d.ts index 8dc5b62..4d1a15a 100644 --- a/src/renderer/window.d.ts +++ b/src/renderer/window.d.ts @@ -4,6 +4,7 @@ import type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabsStateSnapshot, } from '../shared/ipc-contract'; @@ -28,6 +29,9 @@ declare global { upsertBookmark: (payload: BookmarkUpsertPayload) => Promise; removeBookmark: (bookmarkId: number) => Promise; onBookmarksChanged: (callback: (bookmarks: BookmarkSnapshot[]) => void) => () => void; + getHistory: () => Promise; + clearHistory: () => Promise; + onHistoryChanged: (callback: (history: HistorySnapshot[]) => void) => () => void; platform: string; }; } diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 2b5f767..f6c506b 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -32,6 +32,14 @@ export interface BookmarkUpsertPayload { title: string; } +export interface HistorySnapshot { + id: number; + url: string; + title: string; + visitCount: number; + lastVisitedAt: string; +} + export const IPC_CHANNELS = { TOGGLE_FLOAT: 'toggle-float', FLOAT_NAVIGATE: 'float-navigate', @@ -51,4 +59,7 @@ export const IPC_CHANNELS = { BOOKMARKS_UPSERT: 'bookmarks-upsert', BOOKMARKS_REMOVE: 'bookmarks-remove', BOOKMARKS_CHANGED: 'bookmarks-changed', + HISTORY_GET: 'history-get', + HISTORY_CLEAR: 'history-clear', + HISTORY_CHANGED: 'history-changed', } as const; diff --git a/src/shared/ipc-preload.ts b/src/shared/ipc-preload.ts index 6123574..4631d74 100644 --- a/src/shared/ipc-preload.ts +++ b/src/shared/ipc-preload.ts @@ -3,6 +3,7 @@ import type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabSnapshot, TabsStateSnapshot, } from './ipc-contract'; @@ -169,3 +170,35 @@ export function parseBookmarksSnapshotPayload(payload: unknown): BookmarkSnapsho return payload; } + +function isHistorySnapshot(payload: unknown): payload is HistorySnapshot { + if (!payload || typeof payload !== 'object') { + return false; + } + + const historyEntry = payload as Record; + return ( + typeof historyEntry.id === 'number' && + Number.isInteger(historyEntry.id) && + historyEntry.id > 0 && + typeof historyEntry.url === 'string' && + historyEntry.url.length > 0 && + typeof historyEntry.title === 'string' && + typeof historyEntry.visitCount === 'number' && + Number.isInteger(historyEntry.visitCount) && + historyEntry.visitCount >= 0 && + typeof historyEntry.lastVisitedAt === 'string' + ); +} + +export function parseHistorySnapshotsPayload(payload: unknown): HistorySnapshot[] | null { + if (!Array.isArray(payload)) { + return null; + } + + if (!payload.every(isHistorySnapshot)) { + return null; + } + + return payload; +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a5de0be..60aa7b5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -5,6 +5,7 @@ import type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabsStateSnapshot, } from './ipc-contract'; export { IPC_CHANNELS } from './ipc-contract'; @@ -12,6 +13,7 @@ export type { BookmarkSnapshot, BookmarkUpsertPayload, BrowserBounds, + HistorySnapshot, TabSnapshot, TabsStateSnapshot, } from './ipc-contract'; @@ -54,6 +56,14 @@ const BookmarkUpsertPayloadSchema = z.object({ title: z.string().trim().max(512).optional().nullable(), }); const BookmarksSnapshotPayloadSchema = z.array(BookmarkSnapshotPayloadSchema); +const HistorySnapshotPayloadSchema = z.object({ + id: z.number().int().positive(), + url: z.string().trim().min(1), + title: z.string(), + visitCount: z.number().int().nonnegative(), + lastVisitedAt: z.string(), +}); +const HistorySnapshotsPayloadSchema = z.array(HistorySnapshotPayloadSchema); export function parseFloatNavigatePayload(payload: unknown): string | null { const parsedPayload = FloatNavigatePayloadSchema.safeParse(payload); @@ -128,3 +138,8 @@ export function parseBookmarksSnapshotPayload(payload: unknown): BookmarkSnapsho const parsedPayload = BookmarksSnapshotPayloadSchema.safeParse(payload); return parsedPayload.success ? parsedPayload.data : null; } + +export function parseHistorySnapshotsPayload(payload: unknown): HistorySnapshot[] | null { + const parsedPayload = HistorySnapshotsPayloadSchema.safeParse(payload); + return parsedPayload.success ? parsedPayload.data : null; +} diff --git a/tests/renderer-contract.test.ts b/tests/renderer-contract.test.ts index 75cc73f..6bc54e9 100644 --- a/tests/renderer-contract.test.ts +++ b/tests/renderer-contract.test.ts @@ -24,6 +24,7 @@ describe('renderer contract smoke', () => { 'btn-bookmark', 'btn-bookmark-bar', 'btn-bookmarks', + 'btn-history', 'btn-float', 'bookmark-bar', 'bookmark-bar-list', @@ -39,6 +40,10 @@ describe('renderer contract smoke', () => { 'bookmarks-sidebar', 'bookmarks-list', 'bookmarks-empty', + 'history-sidebar', + 'btn-history-clear', + 'history-list', + 'history-empty', ]; requiredIds.forEach(id => {