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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ format is based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]

### Added
- **Symbol extraction.** Captured changes now record the functions/classes they
touched (via VS Code's document symbol provider), so symbol history, the
clickable symbol tags, and symbol-aware search actually work. Also records the
active function/class for each change.
- **Inline history CodeLens.** Unobtrusive lenses above a file ("N changes in
history") and its functions/classes ("k versions"), gated by
`codeHistorian.ui.showInlineHistory`. Click to jump to the file or symbol
history.
- **Undo a restore.** Restoring now offers a one-click Undo via a native
notification (backed by the existing backup mechanism).
- **Native diff actions.** "Compare with current" opens VS Code's diff editor
(file now ↔ before this change); "Open diff" shows the change's unified diff
with syntax highlighting.
- **`codeHistorian.debug` setting** to opt into verbose DEBUG logging.
- DB-layer integration tests (compression, BM25 ranking, bookmarks, retention,
symbol/file lookup) running against sql.js in Node.

### Fixed
- **Duplicate embeddings.** Changes were embedded before their rows were
inserted, leaving `embedding_id` NULL and causing re-embedding (a duplicate
vector) on every restart. Embedding now happens after the flush.
- **File history** used an absolute path against relative-path records and
returned nothing; it now matches correctly.
- API keys and full settings are no longer written to the output channel, and
the output panel no longer pops open on every settings save.

### Added (previously)
- **Zero-config built-in embeddings.** A new `local` embedding provider (now the
default) produces deterministic vectors with no API key, model download, or
local server, so semantic search works on first run.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@

### ⏪ **Code Restoration**
- Restore any previous version of your code with one click
- Preview changes before restoring
- **One-click Undo** after a restore
- Preview changes, or open them in VS Code's native diff editor
(*Compare with current* / *Open diff*)
- Automatic backup creation before restoration
- Works seamlessly with your existing git workflow

### 🔎 **Inline History (CodeLens)**
- *"N changes in history"* above a file and *"k versions"* above each
function/class — click to jump straight to that file's or symbol's history
- Toggle with `codeHistorian.ui.showInlineHistory`

### 📊 **Visual Timeline**
- Beautiful, modern timeline view of all changes
- **Multiple view modes**: Timeline, Cards, or Compact list
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,12 @@
"codeHistorian.ui.showInlineHistory": {
"type": "boolean",
"default": true,
"description": "Show inline history decorations in editor"
"description": "Show inline history CodeLens above files and functions"
},
"codeHistorian.debug": {
"type": "boolean",
"default": false,
"description": "Enable verbose DEBUG logging in the Code Historian output channel"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ export const WEBVIEW_IDS = {
// Event names
export const EVENTS = {
CHANGE_CAPTURED: 'change:captured',
CHANGES_FLUSHED: 'changes:flushed',
SESSION_STARTED: 'session:started',
SESSION_ENDED: 'session:ended',
SEARCH_COMPLETED: 'search:completed',
Expand Down
20 changes: 20 additions & 0 deletions src/database/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
* Save database to file
*/
private saveToFile(): void {
if (!this.db) return;

Check warning on line 173 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

try {
const data = this.db.export();
Expand All @@ -195,7 +195,7 @@
* Create database tables
*/
private createTables(): void {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 198 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

// Changes table
this.db.run(`
Expand Down Expand Up @@ -284,7 +284,7 @@
* Create database indexes for faster queries
*/
private createIndexes(): void {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 287 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

const indexes = [
`CREATE INDEX IF NOT EXISTS idx_changes_timestamp ON ${TABLES.CHANGES}(timestamp)`,
Expand Down Expand Up @@ -312,7 +312,7 @@
* Insert a change record
*/
insertChange(change: ChangeRecord): void {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 315 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

// Compress large diffs to save disk space. We then store an empty `diff`
// column and reconstruct it on read. Small diffs stay as plain text so
Expand Down Expand Up @@ -372,7 +372,7 @@
* Insert multiple changes
*/
insertChanges(changes: ChangeRecord[]): void {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 375 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition
if (changes.length === 0) {
return;
}
Expand All @@ -396,7 +396,7 @@
* Get a change by ID
*/
getChange(id: string): ChangeRecord | null {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 399 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

const results = this.db.exec(
`SELECT * FROM ${TABLES.CHANGES} WHERE id = ?`,
Expand All @@ -419,7 +419,7 @@
limit: number = 100,
offset: number = 0
): ChangeRecord[] {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 422 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

let query = `SELECT * FROM ${TABLES.CHANGES} WHERE workspace_id = ?`;
const params: unknown[] = [workspaceId];
Expand Down Expand Up @@ -488,7 +488,7 @@
query: string,
limit: number = 50
): Array<{ change: ChangeRecord; rank: number }> {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 491 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

// Build an OR of LIKE clauses across query terms to gather candidates.
const terms = query
Expand Down Expand Up @@ -573,12 +573,32 @@
return result;
}

/**
* Get changes for a specific file by its workspace-relative path (exact match).
*/
getChangesForFile(workspaceId: string, relativePath: string, limit: number = 100): ChangeRecord[] {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 580 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

const results = this.db.exec(
`SELECT * FROM ${TABLES.CHANGES}
WHERE workspace_id = ? AND file_path = ?
ORDER BY timestamp DESC
LIMIT ?`,
[workspaceId, relativePath, limit]
);

if (results.length === 0) {
return [];
}
return results[0].values.map(row => this.resultToChangeRecord(results[0].columns, row));
}

/**
* Find the history of a specific symbol (function/class/variable name).
* Matches the stored `symbols` JSON array and the searchable text.
*/
getSymbolHistory(workspaceId: string, symbol: string, limit: number = 100): ChangeRecord[] {
if (!this.db) throw new Error('Database not initialized');

Check warning on line 601 in src/database/metadata.ts

View workflow job for this annotation

GitHub Actions / Lint, typecheck, test & build

Expected { after 'if' condition

// Match the symbol as a JSON array element (e.g. "getUser") or anywhere in
// the searchable text. The leading/trailing quote keeps it reasonably
Expand Down
133 changes: 90 additions & 43 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { EmbeddingService } from './services/embedding';
import { SearchEngine } from './services/search';
import { LLMOrchestrator } from './services/llm';
import { RestorationEngine } from './services/restoration';
import { HistoryCodeLensProvider } from './services/codeLens';
import { ChatParticipant } from './chat/participant';
import { WebviewProvider } from './webview/provider';
import { logger, LogLevel } from './utils/logger';
import { generateWorkspaceId } from './utils';
import { generateWorkspaceId, getRelativePath } from './utils';
import { eventEmitter } from './utils/events';
import { secrets } from './services/secrets';
import { LOCAL_EMBEDDING_DIMENSIONS } from './services/localEmbedding';
Expand All @@ -36,6 +37,7 @@ let embeddingService: EmbeddingService;
let searchEngine: SearchEngine;
let llmOrchestrator: LLMOrchestrator;
let restorationEngine: RestorationEngine;
let codeLensProvider: HistoryCodeLensProvider | undefined;
let _chatParticipant: ChatParticipant;
let webviewProvider: WebviewProvider;
let currentSession: Session | null = null;
Expand Down Expand Up @@ -77,8 +79,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
context.subscriptions.push(outputChannel);
logger.initialize(outputChannel);

// Enable debug logging to help diagnose issues
logger.setLevel(LogLevel.DEBUG);
// Default to INFO; set codeHistorian.debug to surface DEBUG diagnostics.
const debugEnabled = vscode.workspace.getConfiguration(EXTENSION_ID).get<boolean>('debug', false);
logger.setLevel(debugEnabled ? LogLevel.DEBUG : LogLevel.INFO);

logger.info('Activating Code Historian extension');

Expand Down Expand Up @@ -228,22 +231,32 @@ async function initializeServices(context: vscode.ExtensionContext): Promise<voi
// Initialize restoration engine
restorationEngine = new RestorationEngine(workspaceRoot, metadataDb, storagePath);

// Register the inline-history CodeLens provider.
codeLensProvider = new HistoryCodeLensProvider(metadataDb, workspaceId, workspaceRoot);
context.subscriptions.push(
vscode.languages.registerCodeLensProvider({ scheme: 'file' }, codeLensProvider),
codeLensProvider
);

// Initialize capture engine
const captureConfig = getCaptureConfig();
captureEngine = new CaptureEngine(context, workspaceRoot, metadataDb, captureConfig);

// Connect capture engine to embedding service via events
// When a change is captured, process it to generate embeddings
// Connect capture engine to embedding service via events.
// We embed on CHANGES_FLUSHED (after the rows are inserted) rather than on
// CHANGE_CAPTURED. Embedding earlier would call updateEmbeddingId() before the
// row exists, leaving embedding_id NULL and causing the change to be
// re-embedded (a duplicate vector) on every restart.
context.subscriptions.push(
eventEmitter.on(EVENTS.CHANGE_CAPTURED, async change => {
eventEmitter.on(EVENTS.CHANGES_FLUSHED, async (changes: ChangeRecord[]) => {
try {
// Only process if embedding service is configured
if (embeddingService.isConfigured()) {
await embeddingService.processChange(change);
logger.debug(`Generated embedding for change: ${change.id}`);
await embeddingService.processChanges(changes);
// Refresh CodeLens so new history shows up inline.
codeLensProvider?.refresh();
}
} catch (error) {
logger.warn(`Failed to generate embedding for change ${change.id}:`, error as Error);
logger.warn('Failed to generate embeddings for flushed changes:', error as Error);
// Don't throw - embedding failure shouldn't block capture
}
})
Expand Down Expand Up @@ -306,19 +319,17 @@ function registerCommands(context: vscode.ExtensionContext): void {
return;
}

const changes = metadataDb.getChanges(
workspaceId,
{
filePatterns: [fileUri.fsPath],
},
100
);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const relativePath = getRelativePath(fileUri.fsPath, workspaceRoot);
const changes = metadataDb.getChangesForFile(workspaceId, relativePath, 200);

if (changes.length === 0) {
vscode.window.showInformationMessage('No history found for this file');
return;
}

await vscode.commands.executeCommand('workbench.view.extension.codeHistorian');

// Show results in webview
webviewProvider.postMessage({
type: 'searchResults',
Expand Down Expand Up @@ -415,22 +426,27 @@ function registerCommands(context: vscode.ExtensionContext): void {

// Show symbol history command — time-travel a single function/class/variable
context.subscriptions.push(
vscode.commands.registerCommand(COMMANDS.SHOW_SYMBOL_HISTORY, async () => {
// Default to the word under the cursor, if any.
const editor = vscode.window.activeTextEditor;
let defaultSymbol = '';
if (editor) {
const range = editor.document.getWordRangeAtPosition(editor.selection.active);
if (range) {
defaultSymbol = editor.document.getText(range);
vscode.commands.registerCommand(COMMANDS.SHOW_SYMBOL_HISTORY, async (symbolArg?: string) => {
let symbol = typeof symbolArg === 'string' ? symbolArg : undefined;

// No argument (invoked from palette/context menu): prompt, defaulting to
// the word under the cursor.
if (!symbol) {
const editor = vscode.window.activeTextEditor;
let defaultSymbol = '';
if (editor) {
const range = editor.document.getWordRangeAtPosition(editor.selection.active);
if (range) {
defaultSymbol = editor.document.getText(range);
}
}
}

const symbol = await vscode.window.showInputBox({
prompt: 'Show history for symbol (function, class, or variable name)',
placeHolder: 'e.g. parseConfig',
value: defaultSymbol,
});
symbol = await vscode.window.showInputBox({
prompt: 'Show history for symbol (function, class, or variable name)',
placeHolder: 'e.g. parseConfig',
value: defaultSymbol,
});
}

if (!symbol) {
return;
Expand Down Expand Up @@ -637,6 +653,24 @@ async function handleWebviewMessage(message: WebviewToExtensionMessage): Promise

if (result.success) {
await webviewProvider.sendToast('success', `Restored ${result.linesRestored} line(s)`);
codeLensProvider?.refresh();

// Offer a one-click Undo via a native notification when a backup
// was created.
if (result.backupId) {
const backupId = result.backupId;
void vscode.window
.showInformationMessage(`Restored ${result.linesRestored} line(s).`, 'Undo')
.then(async choice => {
if (choice === 'Undo') {
const undone = await restorationEngine.undoRestoration(backupId);
await webviewProvider.sendToast(
undone ? 'info' : 'error',
undone ? 'Restore undone' : 'Could not undo restore'
);
}
});
}
} else {
await webviewProvider.sendToast('error', `Restoration failed: ${result.error}`);
}
Expand All @@ -647,18 +681,39 @@ async function handleWebviewMessage(message: WebviewToExtensionMessage): Promise
break;
}

case 'compareWithCurrent': {
// Reuse the restoration preview, which opens VS Code's native diff
// editor (current file ↔ the file as it was before this change).
try {
await restorationEngine.previewRestoration(message.data.changeId);
} catch (error) {
await webviewProvider.sendToast(
'error',
`Cannot compare: ${(error as Error).message}`
);
}
break;
}

case 'openDiff': {
try {
await restorationEngine.openDiff(message.data.changeId);
} catch (error) {
await webviewProvider.sendToast('error', `Cannot open diff: ${(error as Error).message}`);
}
break;
}

case 'getSettings': {
await webviewProvider.postMessage({ type: 'settings', data: getCurrentSettings() });
break;
}

case 'updateSettings': {
logger.info('Received updateSettings from webview:', JSON.stringify(message.data, null, 2));
logger.show(); // Show the output channel for debugging
// Note: settings payloads can contain API keys, so we don't log them.
await updateSettings(message.data);
// Send back the confirmed settings from VS Code configuration
const confirmedSettings = getCurrentSettings();
logger.info('Sending back confirmed settings:', JSON.stringify(confirmedSettings, null, 2));
await webviewProvider.postMessage({ type: 'settings', data: confirmedSettings });
await webviewProvider.sendToast('success', 'Settings saved');
break;
Expand Down Expand Up @@ -1172,8 +1227,6 @@ function getCurrentSettings(): SettingsData {
async function updateSettings(settings: Partial<SettingsData>): Promise<void> {
const config = vscode.workspace.getConfiguration(EXTENSION_ID);

logger.info('updateSettings called with:', JSON.stringify(settings, null, 2));

// Capture settings
if (settings.capture) {
if (settings.capture.enabled !== undefined) {
Expand Down Expand Up @@ -1303,7 +1356,6 @@ async function updateSettings(settings: Partial<SettingsData>): Promise<void> {
// Using a small delay to ensure VS Code has updated the configuration
await new Promise(resolve => setTimeout(resolve, 100));
const llmConfig = getLLMConfig();
logger.info('LLM config after save:', JSON.stringify(llmConfig));
llmOrchestrator.updateConfig(llmConfig);
}

Expand Down Expand Up @@ -1332,12 +1384,7 @@ async function updateSettings(settings: Partial<SettingsData>): Promise<void> {
// Wait for VS Code configuration to fully propagate
await new Promise(resolve => setTimeout(resolve, 200));

// Log the final settings after all updates
const finalSettings = getCurrentSettings();
logger.info(
'Settings saved successfully. Final settings:',
JSON.stringify(finalSettings, null, 2)
);
logger.info('Settings saved');
}

/**
Expand Down
Loading
Loading