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 => {