diff --git a/apps/frilvault-cli/src/app.rs b/apps/frilvault-cli/src/app.rs index 0c38e28..2b63f67 100644 --- a/apps/frilvault-cli/src/app.rs +++ b/apps/frilvault-cli/src/app.rs @@ -1,11 +1,11 @@ use anyhow::Result; use frilvault_core::{ - NoteService, PathResolver, WorkspaceIndexRepository, WorkspaceRepository, WorkspaceService, - YamlNoteRepository, + NoteService, PathResolver, VaultContext, WorkspaceIndexRepository, WorkspaceRepository, + WorkspaceService, YamlNoteRepository, }; -pub fn create_note_service() -> Result> { +pub fn create_note_service() -> Result { let workspace_root = std::env::current_dir()?; let resolver = PathResolver::new(workspace_root); @@ -13,9 +13,13 @@ pub fn create_note_service() -> Result> { let workspace_repository = WorkspaceRepository::new(resolver.clone()); workspace_repository.create_if_missing()?; - let note_repository = YamlNoteRepository::new(resolver); + let index_repository = WorkspaceIndexRepository::new(resolver.clone()); + index_repository.create_if_missing()?; + + let note_repository = YamlNoteRepository::new(resolver.clone()); + let vault_context = VaultContext::new(note_repository, index_repository); - Ok(NoteService::new(note_repository)) + Ok(NoteService::new(vault_context)) } pub fn create_workspace_service() -> anyhow::Result { @@ -23,9 +27,14 @@ pub fn create_workspace_service() -> anyhow::Result { let resolver = PathResolver::new(workspace_root); - let note_repository = YamlNoteRepository::new(resolver.clone()); + let workspace_repository = WorkspaceRepository::new(resolver.clone()); + workspace_repository.create_if_missing()?; - let index_repository = WorkspaceIndexRepository::new(resolver); + let index_repository = WorkspaceIndexRepository::new(resolver.clone()); + index_repository.create_if_missing()?; + + let note_repository = YamlNoteRepository::new(resolver); + let vault_context = VaultContext::new(note_repository, index_repository.clone()); - Ok(WorkspaceService::new(note_repository, index_repository)) + Ok(WorkspaceService::new(vault_context, index_repository)) } diff --git a/apps/frilvault-cli/src/cli/mod.rs b/apps/frilvault-cli/src/cli/mod.rs index 8bb9231..3bf95a8 100644 --- a/apps/frilvault-cli/src/cli/mod.rs +++ b/apps/frilvault-cli/src/cli/mod.rs @@ -30,5 +30,6 @@ pub enum Commands { Search(SearchCommand), Repair(RepairCommand), Doctor, + Health, Stats, } diff --git a/apps/frilvault-cli/src/cli/search.rs b/apps/frilvault-cli/src/cli/search.rs index 143b3bf..0e12d53 100644 --- a/apps/frilvault-cli/src/cli/search.rs +++ b/apps/frilvault-cli/src/cli/search.rs @@ -1,9 +1,21 @@ -use clap::Args; +use clap::{Args, ValueEnum}; #[derive(Debug, Args)] pub struct SearchCommand { - pub keyword: String, + pub keyword: Option, #[arg(long)] + pub file: Option, + + #[arg(long, value_enum)] + pub format: Option, + + #[arg(long, hide = true)] pub json: bool, } + +#[derive(Debug, Clone, ValueEnum)] +pub enum SearchFormatArg { + Text, + Json, +} diff --git a/apps/frilvault-cli/src/command/add.rs b/apps/frilvault-cli/src/command/add.rs index 49bd4a8..09dde92 100644 --- a/apps/frilvault-cli/src/command/add.rs +++ b/apps/frilvault-cli/src/command/add.rs @@ -8,7 +8,7 @@ use crate::{ }; pub fn execute(command: AddCommand) -> Result<()> { - let service = create_note_service()?; + let mut service = create_note_service()?; let anchor = create_anchor(&command)?; @@ -55,3 +55,58 @@ impl From for SymbolKind { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_anchor_builds_line_anchor() { + let command = AddCommand { + file: "src/main.rs".to_string(), + line: Some(12), + column: Some(3), + symbol: None, + kind: SymbolKindArg::Unknown, + signature: None, + line_hint: None, + content: "note".to_string(), + }; + + let anchor = create_anchor(&command).unwrap(); + + match anchor { + NoteAnchor::Line(line) => { + assert_eq!(line.line, 12); + assert_eq!(line.column, 3); + } + _ => panic!("expected line anchor"), + } + } + + #[test] + fn create_anchor_builds_symbol_anchor() { + let command = AddCommand { + file: "src/main.rs".to_string(), + line: None, + column: None, + symbol: Some("main".to_string()), + kind: SymbolKindArg::Function, + signature: Some("fn main()".to_string()), + line_hint: Some(1), + content: "note".to_string(), + }; + + let anchor = create_anchor(&command).unwrap(); + + match anchor { + NoteAnchor::Symbol(symbol) => { + assert_eq!(symbol.name, "main"); + assert_eq!(symbol.kind, SymbolKind::Function); + assert_eq!(symbol.signature.as_deref(), Some("fn main()")); + assert_eq!(symbol.line_hint, Some(1)); + } + _ => panic!("expected symbol anchor"), + } + } +} diff --git a/apps/frilvault-cli/src/command/delete.rs b/apps/frilvault-cli/src/command/delete.rs index 9ce194d..fcdaa58 100644 --- a/apps/frilvault-cli/src/command/delete.rs +++ b/apps/frilvault-cli/src/command/delete.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::{app::create_note_service, cli::delete::DeleteCommand}; pub fn execute(command: DeleteCommand) -> Result<()> { - let service = create_note_service()?; + let mut service = create_note_service()?; service.delete_note(&command.file, Uuid::parse_str(&command.id)?)?; diff --git a/apps/frilvault-cli/src/command/doctor.rs b/apps/frilvault-cli/src/command/doctor.rs index d5c45e5..0a6766a 100644 --- a/apps/frilvault-cli/src/command/doctor.rs +++ b/apps/frilvault-cli/src/command/doctor.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::app::create_workspace_service; pub fn execute() -> Result<()> { - let service = create_workspace_service()?; + let mut service = create_workspace_service()?; let health = service.health_check()?; diff --git a/apps/frilvault-cli/src/command/list.rs b/apps/frilvault-cli/src/command/list.rs index a350e34..8dbc0c6 100644 --- a/apps/frilvault-cli/src/command/list.rs +++ b/apps/frilvault-cli/src/command/list.rs @@ -7,7 +7,7 @@ use crate::{ }; pub fn execute(command: ListCommand) -> Result<()> { - let service = create_note_service()?; + let mut service = create_note_service()?; let notes = service.list_notes(&command.file)?; diff --git a/apps/frilvault-cli/src/command/repair.rs b/apps/frilvault-cli/src/command/repair.rs index 4d060e7..51f04e2 100644 --- a/apps/frilvault-cli/src/command/repair.rs +++ b/apps/frilvault-cli/src/command/repair.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::{app::create_workspace_service, cli::repair::RepairCommand}; pub fn execute(command: RepairCommand) -> Result<()> { - let service = create_workspace_service()?; + let mut service = create_workspace_service()?; if command.apply { let repaired = service.apply_repairs()?; diff --git a/apps/frilvault-cli/src/command/search.rs b/apps/frilvault-cli/src/command/search.rs index 3511b00..c7b9553 100644 --- a/apps/frilvault-cli/src/command/search.rs +++ b/apps/frilvault-cli/src/command/search.rs @@ -2,19 +2,27 @@ use anyhow::Result; use crate::{ app::create_note_service, - cli::search::SearchCommand, + cli::search::{SearchCommand, SearchFormatArg}, output::{OutputFormat, print_notes}, }; pub fn execute(command: SearchCommand) -> Result<()> { - let service = create_note_service()?; + let mut service = create_note_service()?; - let results = service.search_notes(&command.keyword)?; + let results = match (command.keyword.as_deref(), command.file.as_deref()) { + (Some(keyword), Some(file)) => service + .search_notes(keyword)? + .into_iter() + .filter(|note| note.source_file.to_string_lossy() == file) + .collect(), + (Some(keyword), None) => service.search_notes(keyword)?, + (None, Some(file)) => service.list_notes(file)?, + (None, None) => anyhow::bail!("search requires either a keyword or --file"), + }; - let format = if command.json { - OutputFormat::Json - } else { - OutputFormat::Text + let format = match (command.format, command.json) { + (Some(SearchFormatArg::Json), _) | (None, true) => OutputFormat::Json, + _ => OutputFormat::Text, }; print_notes(&results, format)?; diff --git a/apps/frilvault-cli/src/command/stats.rs b/apps/frilvault-cli/src/command/stats.rs index 0669285..b96b498 100644 --- a/apps/frilvault-cli/src/command/stats.rs +++ b/apps/frilvault-cli/src/command/stats.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::app::create_workspace_service; pub fn execute() -> Result<()> { - let service = create_workspace_service()?; + let mut service = create_workspace_service()?; let stats = service.stats()?; diff --git a/apps/frilvault-cli/src/command/update.rs b/apps/frilvault-cli/src/command/update.rs index f78ec88..04f5691 100644 --- a/apps/frilvault-cli/src/command/update.rs +++ b/apps/frilvault-cli/src/command/update.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::{app::create_note_service, cli::update::UpdateCommand}; pub fn execute(command: UpdateCommand) -> Result<()> { - let service = create_note_service()?; + let mut service = create_note_service()?; service.update_note( &command.file, diff --git a/apps/frilvault-cli/src/main.rs b/apps/frilvault-cli/src/main.rs index c7213c8..197f3f8 100644 --- a/apps/frilvault-cli/src/main.rs +++ b/apps/frilvault-cli/src/main.rs @@ -34,6 +34,10 @@ fn main() -> Result<()> { command::doctor::execute()?; } + Commands::Health => { + command::doctor::execute()?; + } + Commands::Stats => command::stats::execute()?, Commands::Repair(cmd) => { @@ -43,3 +47,6 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/apps/frilvault-cli/src/output.rs b/apps/frilvault-cli/src/output.rs index 14e0b2e..e1ff118 100644 --- a/apps/frilvault-cli/src/output.rs +++ b/apps/frilvault-cli/src/output.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use frilvault_core::{NoteAnchor, NoteView}; +use frilvault_core::{NoteAnchor, note_view::NoteView}; #[derive(Debug, Clone, Copy)] pub enum OutputFormat { diff --git a/apps/frilvault-cli/src/tests.rs b/apps/frilvault-cli/src/tests.rs new file mode 100644 index 0000000..edb9c4e --- /dev/null +++ b/apps/frilvault-cli/src/tests.rs @@ -0,0 +1,70 @@ +use clap::Parser; + +use crate::cli::{Cli, Commands, add::SymbolKindArg, list::ListFormatArg, search::SearchFormatArg}; + +#[test] +fn parses_list_format_json() { + let cli = Cli::parse_from(["flvt", "list", "--file", "src/main.rs", "--format", "json"]); + + match cli.command { + Commands::List(command) => { + assert_eq!(command.file, "src/main.rs"); + assert!(matches!(command.format, Some(ListFormatArg::Json))); + assert!(!command.json); + } + _ => panic!("expected list command"), + } +} + +#[test] +fn parses_search_with_file_and_json_format() { + let cli = Cli::parse_from([ + "flvt", + "search", + "--file", + "src/main.rs", + "--format", + "json", + ]); + + match cli.command { + Commands::Search(command) => { + assert_eq!(command.keyword, None); + assert_eq!(command.file.as_deref(), Some("src/main.rs")); + assert!(matches!(command.format, Some(SearchFormatArg::Json))); + } + _ => panic!("expected search command"), + } +} + +#[test] +fn parses_health_command_alias() { + let cli = Cli::parse_from(["flvt", "health"]); + + assert!(matches!(cli.command, Commands::Health)); +} + +#[test] +fn parses_symbol_add_command() { + let cli = Cli::parse_from([ + "flvt", + "add", + "--file", + "src/main.rs", + "--symbol", + "main", + "--kind", + "function", + "--content", + "note", + ]); + + match cli.command { + Commands::Add(command) => { + assert_eq!(command.symbol.as_deref(), Some("main")); + assert!(matches!(command.kind, SymbolKindArg::Function)); + assert_eq!(command.content, "note"); + } + _ => panic!("expected add command"), + } +} diff --git a/apps/vscode-extension/media/frilvault-note-gutter.svg b/apps/vscode-extension/media/frilvault-line-note.svg similarity index 100% rename from apps/vscode-extension/media/frilvault-note-gutter.svg rename to apps/vscode-extension/media/frilvault-line-note.svg diff --git a/apps/vscode-extension/media/frilvault-symbol-note.svg b/apps/vscode-extension/media/frilvault-symbol-note.svg new file mode 100644 index 0000000..02cff82 --- /dev/null +++ b/apps/vscode-extension/media/frilvault-symbol-note.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 17a0d69..c4a8f10 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -34,14 +34,6 @@ "command": "frilvault.addNote", "title": "FrilVault: Add Note" }, - { - "command": "frilvault.editNote", - "title": "FrilVault: Edit Note" - }, - { - "command": "frilvault.deleteNote", - "title": "FrilVault: Delete Note" - }, { "command": "frilvault.searchNotes", "title": "FrilVault: Search Notes" @@ -76,18 +68,6 @@ "group": "navigation" } ], - "view/item/context": [ - { - "command": "frilvault.editNote", - "when": "view == frilvault.notes", - "group": "inline" - }, - { - "command": "frilvault.deleteNote", - "when": "view == frilvault.notes", - "group": "inline" - } - ], "editor/context": [ { "command": "frilvault.addNote", diff --git a/apps/vscode-extension/src/core/cliClient.ts b/apps/vscode-extension/src/core/cliClient.ts new file mode 100644 index 0000000..1156632 --- /dev/null +++ b/apps/vscode-extension/src/core/cliClient.ts @@ -0,0 +1,136 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import * as vscode from 'vscode'; + +import type { NoteView } from '../types'; +import { parseJson } from '../utils/parser'; + +const execFileAsync = promisify(execFile); + +export function getConfiguredCliPath(): string { + return vscode.workspace + .getConfiguration('frilvault') + .get('cliPath', 'flvt') + .trim(); +} + +export interface AddLineNoteInput { + workspaceRoot: string; + sourceFile: string; + line: number; + column: number; + content: string; +} + +export interface SearchNotesInput { + workspaceRoot: string; + keyword?: string; + sourceFile?: string; +} + +export class CliClient { + public constructor(private readonly getCliPath = getConfiguredCliPath) {} + + public async addLineNote(input: AddLineNoteInput): Promise { + await this.execInWorkspace(input.workspaceRoot, [ + 'add', + '--file', + input.sourceFile, + '--line', + String(input.line), + '--column', + String(input.column), + '--content', + input.content, + ]); + } + + public async listNotes(workspaceRoot: string, sourceFile: string): Promise { + const stdout = await this.execInWorkspace(workspaceRoot, [ + 'list', + '--file', + sourceFile, + '--format', + 'json', + ]); + + return parseJson(stdout); + } + + public async searchNotes(input: SearchNotesInput): Promise { + const args = ['search']; + + if (input.keyword) { + args.push(input.keyword); + } + + if (input.sourceFile) { + args.push('--file', input.sourceFile); + } + + args.push('--format', 'json'); + + const stdout = await this.execInWorkspace(input.workspaceRoot, args); + + return parseJson(stdout); + } + + public async updateNote( + workspaceRoot: string, + sourceFile: string, + noteId: string, + content: string, + ): Promise { + await this.execInWorkspace(workspaceRoot, [ + 'update', + '--file', + sourceFile, + '--id', + noteId, + '--content', + content, + ]); + } + + public async deleteNote( + workspaceRoot: string, + sourceFile: string, + noteId: string, + ): Promise { + await this.execInWorkspace(workspaceRoot, [ + 'delete', + '--file', + sourceFile, + '--id', + noteId, + ]); + } + + public async stats(workspaceRoot: string): Promise { + return this.execInWorkspace(workspaceRoot, ['stats']); + } + + public async health(workspaceRoot: string): Promise { + return this.execInWorkspace(workspaceRoot, ['health']); + } + + public async repair(workspaceRoot: string, apply = false): Promise { + return this.execInWorkspace(workspaceRoot, apply ? ['repair', '--apply'] : ['repair']); + } + + private async execInWorkspace(workspaceRoot: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync(this.getCliPath(), args, { + cwd: workspaceRoot, + }); + + return stdout.trim(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to execute FrilVault CLI.'; + + throw new Error(message); + } + } +} diff --git a/apps/vscode-extension/src/native.ts b/apps/vscode-extension/src/core/nodeBridge.ts similarity index 79% rename from apps/vscode-extension/src/native.ts rename to apps/vscode-extension/src/core/nodeBridge.ts index 058c4eb..8aafbeb 100644 --- a/apps/vscode-extension/src/native.ts +++ b/apps/vscode-extension/src/core/nodeBridge.ts @@ -1,13 +1,8 @@ -import * as path from 'path'; +import * as path from 'node:path'; import * as vscode from 'vscode'; -import type { - MutationResult, - NoteView, - RepairSuggestion, - WorkspaceHealth, - WorkspaceStats, -} from './types'; +import type { MutationResult, RepairSuggestion, NoteView, WorkspaceHealth, WorkspaceStats } from '../types'; +import { parseJson } from '../utils/parser'; interface NativeBinding { addLineNote( @@ -18,12 +13,7 @@ interface NativeBinding { content: string, ): string; listNotes(workspaceRoot: string, sourceFile: string): string; - updateNote( - workspaceRoot: string, - sourceFile: string, - noteId: string, - content: string, - ): string; + updateNote(workspaceRoot: string, sourceFile: string, noteId: string, content: string): string; deleteNote(workspaceRoot: string, sourceFile: string, noteId: string): void; searchNotes(workspaceRoot: string, keyword: string): string; workspaceStats(workspaceRoot: string): string; @@ -32,16 +22,11 @@ interface NativeBinding { applyRepairs(workspaceRoot: string): number; } -function parseJson(value: string): T { - return JSON.parse(value) as T; -} - -export class FrilVaultNativeClient { +export class NodeBridge { private readonly binding: NativeBinding; - public constructor(private readonly context: vscode.ExtensionContext) { - const modulePath = path.join(context.extensionPath, 'dist', 'frilvault.node'); - this.binding = require(modulePath) as NativeBinding; + public constructor(context: vscode.ExtensionContext) { + this.binding = require(path.join(context.extensionPath, 'dist', 'frilvault.node')) as NativeBinding; } public addLineNote( diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index d411e93..3270939 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,291 +1,80 @@ -import * as path from 'path'; import * as vscode from 'vscode'; +import { CliClient } from './core/cliClient'; +import { NodeBridge } from './core/nodeBridge'; import { createAddNoteCommand } from './features/add-note/command'; -import { DecorationsProvider } from './features/decorations/provider'; -import { - FrilVaultNoteTreeItem, - FrilVaultNotesProvider, -} from './features/notes-panel/provider'; -import { FrilVaultNativeClient } from './native'; -import type { NoteView, RepairSuggestion, WorkspaceHealth, WorkspaceStats } from './types'; - -function getWorkspaceRoot(): string { - const configured = vscode.workspace - .getConfiguration('frilvault') - .get('workspaceRoot', '') - .trim(); - - if (configured.length > 0) { - return configured; - } - - const folder = vscode.workspace.workspaceFolders?.[0]; - if (!folder) { - throw new Error('FrilVault requires an open workspace folder.'); - } - - return folder.uri.fsPath; -} - -function getActiveEditorOrThrow(): vscode.TextEditor { - const editor = vscode.window.activeTextEditor; - if (!editor) { - throw new Error('No active editor.'); - } - - return editor; -} - -function getActiveFilePathOrThrow(): string { - const editor = getActiveEditorOrThrow(); - - if (editor.document.uri.scheme !== 'file') { - throw new Error('FrilVault only supports files on disk.'); - } - - return editor.document.uri.fsPath; -} - -function formatStats(stats: WorkspaceStats): string { - return [ - `files: ${stats.file_count}`, - `notes: ${stats.total_notes}`, - `existing files: ${stats.existing_files}`, - `missing files: ${stats.missing_files}`, - `line notes: ${stats.line_notes}`, - `symbol notes: ${stats.symbol_notes}`, - ].join('\n'); -} - -function formatHealth(health: WorkspaceHealth): string { - if (health.missing_source_files.length === 0) { - return 'No missing source files.'; - } - - return ['Missing source files:', ...health.missing_source_files].join('\n'); -} - -function formatRepairSuggestions(suggestions: RepairSuggestion[]): string { - if (suggestions.length === 0) { - return 'No repair suggestions.'; - } - - return suggestions - .map((suggestion) => { - const candidates = - suggestion.candidates.length > 0 - ? suggestion.candidates.join(', ') - : 'no candidates'; - - return `${suggestion.missing_file} -> ${candidates}`; - }) - .join('\n'); -} - -async function revealNote(note: NoteView, workspaceRoot: string): Promise { - const sourcePath = path.join(workspaceRoot, note.source_file); - const document = await vscode.workspace.openTextDocument(sourcePath); - const editor = await vscode.window.showTextDocument(document); - - const line = - note.note.anchor.type === 'Line' - ? Math.max(note.note.anchor.line - 1, 0) - : Math.max((note.note.anchor.line_hint ?? 1) - 1, 0); - const column = note.note.anchor.type === 'Line' ? Math.max(note.note.anchor.column - 1, 0) : 0; - const position = new vscode.Position(line, column); - editor.selection = new vscode.Selection(position, position); - editor.revealRange(new vscode.Range(position, position)); -} - -async function promptNoteContent(initialValue = ''): Promise { - const content = await vscode.window.showInputBox({ - prompt: 'Enter a FrilVault note', - value: initialValue, - ignoreFocusOut: true, - validateInput(value) { - return value.trim().length === 0 ? 'Note content is required.' : undefined; - }, - }); - - return content?.trim(); -} - -async function editNote( - client: FrilVaultNativeClient, - provider: FrilVaultNotesProvider, - decorationsProvider: DecorationsProvider, - item?: FrilVaultNoteTreeItem, -): Promise { - const target = item ?? (await pickCurrentFileNote(client)); - if (!target) { - return; - } - - const workspaceRoot = getWorkspaceRoot(); - const sourceFile = path.join(workspaceRoot, target.noteView.source_file); - const content = await promptNoteContent(target.noteView.note.content); - - if (!content) { - return; - } - - client.updateNote(workspaceRoot, sourceFile, target.noteView.note.id, content); - provider.refresh(); - await decorationsProvider.refresh(); -} - -async function deleteNote( - client: FrilVaultNativeClient, - provider: FrilVaultNotesProvider, - decorationsProvider: DecorationsProvider, - item?: FrilVaultNoteTreeItem, -): Promise { - const target = item ?? (await pickCurrentFileNote(client)); - if (!target) { - return; - } - - const confirmed = await vscode.window.showWarningMessage( - 'Delete this FrilVault note?', - { modal: true }, - 'Delete', - ); - - if (confirmed !== 'Delete') { - return; - } - - const workspaceRoot = getWorkspaceRoot(); - const sourceFile = path.join(workspaceRoot, target.noteView.source_file); - client.deleteNote(workspaceRoot, sourceFile, target.noteView.note.id); - provider.refresh(); - await decorationsProvider.refresh(); -} - -async function pickCurrentFileNote( - client: FrilVaultNativeClient, -): Promise { - const workspaceRoot = getWorkspaceRoot(); - const sourceFile = getActiveFilePathOrThrow(); - const notes = client.listNotes(workspaceRoot, sourceFile); - - if (notes.length === 0) { - vscode.window.showInformationMessage('No FrilVault notes found for the current file.'); - return undefined; - } - - const picked = await vscode.window.showQuickPick( - notes.map((noteView) => ({ - label: noteView.note.content, - description: - noteView.note.anchor.type === 'Line' - ? `L${noteView.note.anchor.line}:C${noteView.note.anchor.column}` - : `${noteView.note.anchor.kind}: ${noteView.note.anchor.name}`, - noteView, - })), - { placeHolder: 'Select a FrilVault note' }, - ); - - return picked ? new FrilVaultNoteTreeItem(picked.noteView, sourceFile) : undefined; -} - -async function searchNotes(client: FrilVaultNativeClient): Promise { - const workspaceRoot = getWorkspaceRoot(); - const keyword = await vscode.window.showInputBox({ - prompt: 'Search FrilVault notes', - ignoreFocusOut: true, - }); - - if (!keyword || keyword.trim().length === 0) { - return; - } - - const results = client.searchNotes(workspaceRoot, keyword.trim()); - if (results.length === 0) { - vscode.window.showInformationMessage(`No notes found for "${keyword}".`); - return; - } - - const picked = await vscode.window.showQuickPick( - results.map((note) => ({ - label: note.note.content, - description: note.source_file, - detail: - note.note.anchor.type === 'Line' - ? `Line ${note.note.anchor.line}, Column ${note.note.anchor.column}` - : `${note.note.anchor.kind}: ${note.note.anchor.name}`, - note, - })), - { placeHolder: `Found ${results.length} note(s)` }, - ); - - if (picked) { - await revealNote(picked.note, workspaceRoot); - } -} - -function showTextDocument(title: string, body: string): void { - const channel = vscode.window.createOutputChannel(title); - channel.clear(); - channel.appendLine(body); - channel.show(true); -} +import { AddNoteService } from './features/add-note/service'; +import { FrilVaultDecorator } from './features/decorations/decorator'; +import { FrilVaultHoverProvider } from './features/hover/hoverProvider'; +import { FrilVaultNotesProvider } from './features/notes-panel/provider'; +import { NotesPanelService } from './features/notes-panel/service'; +import { createSearchCommand } from './features/search/command'; +import { createApplyRepairsCommand, createShowHealthCommand } from './features/workspace/health'; +import { createShowStatsCommand } from './features/workspace/stats'; +import type { NoteView } from './types'; +import { getWorkspaceRoot, revealNote } from './utils/file'; export function activate(context: vscode.ExtensionContext): void { - const client = new FrilVaultNativeClient(context); - const provider = new FrilVaultNotesProvider(getWorkspaceRoot); - const decorationsProvider = new DecorationsProvider(context.extensionPath, getWorkspaceRoot); - const addNoteCommand = createAddNoteCommand({ - getWorkspaceRoot, - noteTreeDataProvider: provider, - decorationsProvider, - }); + const cliClient = new CliClient(); + const nodeBridge = new NodeBridge(context); + const addNoteService = new AddNoteService(cliClient); + const notesPanelService = new NotesPanelService(cliClient); + const notesProvider = new FrilVaultNotesProvider(notesPanelService, getWorkspaceRoot); + const decorator = new FrilVaultDecorator(context.extensionPath, cliClient, getWorkspaceRoot); + const hoverProvider = new FrilVaultHoverProvider(cliClient, getWorkspaceRoot); context.subscriptions.push( - decorationsProvider, - vscode.window.registerTreeDataProvider('frilvault.notes', provider), + decorator, + vscode.window.registerTreeDataProvider('frilvault.notes', notesProvider), + vscode.languages.registerHoverProvider({ scheme: 'file' }, hoverProvider), vscode.commands.registerCommand('frilvault.notesPanel.openNote', async (noteView: NoteView) => { await revealNote(noteView, getWorkspaceRoot()); }), - vscode.commands.registerCommand('frilvault.refresh', () => provider.refresh()), - vscode.commands.registerCommand('frilvault.addNote', addNoteCommand), - vscode.commands.registerCommand('frilvault.editNote', (item?: FrilVaultNoteTreeItem) => - editNote(client, provider, decorationsProvider, item), + vscode.commands.registerCommand( + 'frilvault.addNote', + createAddNoteCommand({ + getWorkspaceRoot, + service: addNoteService, + refreshNotesPanel: () => notesProvider.refresh(), + refreshDecorations: async (editor) => decorator.refresh(editor), + }), ), - vscode.commands.registerCommand('frilvault.deleteNote', (item?: FrilVaultNoteTreeItem) => - deleteNote(client, provider, decorationsProvider, item), + vscode.commands.registerCommand( + 'frilvault.searchNotes', + createSearchCommand(cliClient, getWorkspaceRoot), ), - vscode.commands.registerCommand('frilvault.searchNotes', () => searchNotes(client)), - vscode.commands.registerCommand('frilvault.showStats', () => { - const stats = client.workspaceStats(getWorkspaceRoot()); - showTextDocument('FrilVault Stats', formatStats(stats)); - }), - vscode.commands.registerCommand('frilvault.showHealth', () => { - const health = client.workspaceHealth(getWorkspaceRoot()); - const suggestions = client.repairSuggestions(getWorkspaceRoot()); - showTextDocument( - 'FrilVault Health', - `${formatHealth(health)}\n\n${formatRepairSuggestions(suggestions)}`, - ); - }), - vscode.commands.registerCommand('frilvault.applyRepairs', async () => { - const repaired = client.applyRepairs(getWorkspaceRoot()); - provider.refresh(); - await decorationsProvider.refresh(); - vscode.window.showInformationMessage(`FrilVault repaired ${repaired} file(s).`); + vscode.commands.registerCommand( + 'frilvault.showStats', + createShowStatsCommand(nodeBridge, getWorkspaceRoot), + ), + vscode.commands.registerCommand( + 'frilvault.showHealth', + createShowHealthCommand(nodeBridge, getWorkspaceRoot), + ), + vscode.commands.registerCommand( + 'frilvault.applyRepairs', + createApplyRepairsCommand( + nodeBridge, + getWorkspaceRoot, + () => notesProvider.refresh(), + async () => decorator.refresh(), + ), + ), + vscode.commands.registerCommand('frilvault.refresh', async () => { + notesProvider.refresh(); + await decorator.refresh(); }), vscode.window.onDidChangeActiveTextEditor(async (editor) => { - provider.refresh(); - await decorationsProvider.refresh(editor); + notesProvider.refresh(); + await decorator.refresh(editor); }), vscode.workspace.onDidSaveTextDocument(async () => { - provider.refresh(); - await decorationsProvider.refresh(); + notesProvider.refresh(); + await decorator.refresh(); }), ); - void decorationsProvider.refresh(); + void decorator.refresh(); } export function deactivate(): void {} diff --git a/apps/vscode-extension/src/features/add-note/cli.ts b/apps/vscode-extension/src/features/add-note/cli.ts deleted file mode 100644 index 47d547d..0000000 --- a/apps/vscode-extension/src/features/add-note/cli.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -import * as vscode from 'vscode'; - -const execFileAsync = promisify(execFile); - -export interface AddNoteCliInput { - cliPath: string; - workspaceRoot: string; - relativeFilePath: string; - line: number; - column: number; - content: string; -} - -export async function executeAddNoteCli(input: AddNoteCliInput): Promise { - const args = [ - 'add', - '--file', - input.relativeFilePath, - '--line', - String(input.line), - '--column', - String(input.column), - '--content', - input.content, - ]; - - try { - await execFileAsync(input.cliPath, args, { - cwd: input.workspaceRoot, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to execute FrilVault CLI.'; - - throw new Error(message); - } -} - -export function getConfiguredCliPath(): string { - return vscode.workspace - .getConfiguration('frilvault') - .get('cliPath', 'flvt') - .trim(); -} diff --git a/apps/vscode-extension/src/features/add-note/command.ts b/apps/vscode-extension/src/features/add-note/command.ts index 4904ebd..fd65c12 100644 --- a/apps/vscode-extension/src/features/add-note/command.ts +++ b/apps/vscode-extension/src/features/add-note/command.ts @@ -1,12 +1,13 @@ import * as vscode from 'vscode'; +import { getActiveEditorOrThrow } from '../../utils/file'; import { AddNoteService } from './service'; -import { getConfiguredCliPath } from './cli'; export interface AddNoteCommandDependencies { getWorkspaceRoot: () => string; - noteTreeDataProvider: { refresh(): void }; - decorationsProvider: { refresh(editor?: vscode.TextEditor): Promise }; + service: AddNoteService; + refreshNotesPanel: () => void; + refreshDecorations: (editor?: vscode.TextEditor) => Promise; promptNoteContent?: () => Promise; showInformationMessage?: (message: string) => Thenable; showErrorMessage?: (message: string) => Thenable; @@ -27,7 +28,6 @@ async function promptNoteContent(): Promise { export function createAddNoteCommand( dependencies: AddNoteCommandDependencies, ): () => Promise { - const service = new AddNoteService(); const getNoteContent = dependencies.promptNoteContent ?? promptNoteContent; const showInformationMessage = dependencies.showInformationMessage ?? vscode.window.showInformationMessage; @@ -35,36 +35,26 @@ export function createAddNoteCommand( return async () => { try { - const editor = vscode.window.activeTextEditor; - if (!editor) { - throw new Error('No active editor.'); - } - - if (editor.document.uri.scheme !== 'file') { - throw new Error('FrilVault only supports files on disk.'); - } - + const editor = getActiveEditorOrThrow(); const content = await getNoteContent(); + if (!content) { return; } const line = editor.selection.active.line + 1; const column = editor.selection.active.character + 1; - const workspaceRoot = dependencies.getWorkspaceRoot(); - await service.execute({ - cliPath: getConfiguredCliPath(), - workspaceRoot, + await dependencies.service.execute({ + workspaceRoot: dependencies.getWorkspaceRoot(), sourceFile: editor.document.uri.fsPath, line, column, content, }); - dependencies.noteTreeDataProvider.refresh(); - await dependencies.decorationsProvider.refresh(editor); - + dependencies.refreshNotesPanel(); + await dependencies.refreshDecorations(editor); await showInformationMessage(`FrilVault note added at ${line}:${column}.`); } catch (error) { const message = diff --git a/apps/vscode-extension/src/features/add-note/service.ts b/apps/vscode-extension/src/features/add-note/service.ts index 176a81b..1887a47 100644 --- a/apps/vscode-extension/src/features/add-note/service.ts +++ b/apps/vscode-extension/src/features/add-note/service.ts @@ -1,9 +1,7 @@ -import * as path from 'node:path'; - -import { executeAddNoteCli } from './cli'; +import type { CliClient } from '../../core/cliClient'; +import { getRelativeFilePath } from '../../utils/file'; export interface AddNoteRequest { - cliPath: string; workspaceRoot: string; sourceFile: string; line: number; @@ -12,21 +10,12 @@ export interface AddNoteRequest { } export class AddNoteService { - public async execute(request: AddNoteRequest): Promise { - const relativeFilePath = path.relative(request.workspaceRoot, request.sourceFile); + public constructor(private readonly cliClient: CliClient) {} - if ( - relativeFilePath.length === 0 || - relativeFilePath.startsWith('..') || - path.isAbsolute(relativeFilePath) - ) { - throw new Error('The active file must be inside the current workspace.'); - } - - await executeAddNoteCli({ - cliPath: request.cliPath, + public async execute(request: AddNoteRequest): Promise { + await this.cliClient.addLineNote({ workspaceRoot: request.workspaceRoot, - relativeFilePath, + sourceFile: getRelativeFilePath(request.workspaceRoot, request.sourceFile), line: request.line, column: request.column, content: request.content, diff --git a/apps/vscode-extension/src/features/decorations/decorator.ts b/apps/vscode-extension/src/features/decorations/decorator.ts new file mode 100644 index 0000000..739b906 --- /dev/null +++ b/apps/vscode-extension/src/features/decorations/decorator.ts @@ -0,0 +1,77 @@ +import * as vscode from 'vscode'; + +import type { CliClient } from '../../core/cliClient'; +import { getRelativeFilePath } from '../../utils/file'; +import { createLineNoteDecorationType, createSymbolNoteDecorationType } from './gutter'; + +export class FrilVaultDecorator implements vscode.Disposable { + private readonly lineDecorationType: vscode.TextEditorDecorationType; + + private readonly symbolDecorationType: vscode.TextEditorDecorationType; + + private previousEditor: vscode.TextEditor | undefined; + + public constructor( + extensionPath: string, + private readonly cliClient: CliClient, + private readonly getWorkspaceRoot: () => string, + ) { + this.lineDecorationType = createLineNoteDecorationType(extensionPath); + this.symbolDecorationType = createSymbolNoteDecorationType(extensionPath); + } + + public async refresh(editor = vscode.window.activeTextEditor): Promise { + if (this.previousEditor && this.previousEditor !== editor) { + this.clear(this.previousEditor); + } + + if (!editor || editor.document.uri.scheme !== 'file') { + this.previousEditor = editor; + return; + } + + const notes = await this.cliClient.listNotes( + this.getWorkspaceRoot(), + getRelativeFilePath(this.getWorkspaceRoot(), editor.document.uri.fsPath), + ); + + const lineDecorations: vscode.DecorationOptions[] = []; + const symbolDecorations: vscode.DecorationOptions[] = []; + + for (const note of notes) { + const line = + note.note.anchor.type === 'Line' + ? (note.note.anchor.line ?? 1) - 1 + : (note.note.anchor.line_hint ?? 1) - 1; + + if (line < 0 || line >= editor.document.lineCount) { + continue; + } + + const decoration = { + range: editor.document.lineAt(line).range, + hoverMessage: new vscode.MarkdownString(note.note.content), + }; + + if (note.note.anchor.type === 'Line') { + lineDecorations.push(decoration); + } else { + symbolDecorations.push(decoration); + } + } + + editor.setDecorations(this.lineDecorationType, lineDecorations); + editor.setDecorations(this.symbolDecorationType, symbolDecorations); + this.previousEditor = editor; + } + + public clear(editor = vscode.window.activeTextEditor): void { + editor?.setDecorations(this.lineDecorationType, []); + editor?.setDecorations(this.symbolDecorationType, []); + } + + public dispose(): void { + this.lineDecorationType.dispose(); + this.symbolDecorationType.dispose(); + } +} diff --git a/apps/vscode-extension/src/features/decorations/gutter.ts b/apps/vscode-extension/src/features/decorations/gutter.ts new file mode 100644 index 0000000..028c61e --- /dev/null +++ b/apps/vscode-extension/src/features/decorations/gutter.ts @@ -0,0 +1,19 @@ +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +export function createLineNoteDecorationType(extensionPath: string): vscode.TextEditorDecorationType { + return vscode.window.createTextEditorDecorationType({ + gutterIconPath: vscode.Uri.file(path.join(extensionPath, 'media', 'frilvault-line-note.svg')), + gutterIconSize: 'contain', + }); +} + +export function createSymbolNoteDecorationType( + extensionPath: string, +): vscode.TextEditorDecorationType { + return vscode.window.createTextEditorDecorationType({ + gutterIconPath: vscode.Uri.file(path.join(extensionPath, 'media', 'frilvault-symbol-note.svg')), + gutterIconSize: 'contain', + }); +} diff --git a/apps/vscode-extension/src/features/decorations/provider.ts b/apps/vscode-extension/src/features/decorations/provider.ts deleted file mode 100644 index 574d5c7..0000000 --- a/apps/vscode-extension/src/features/decorations/provider.ts +++ /dev/null @@ -1,72 +0,0 @@ -import * as path from 'node:path'; -import * as vscode from 'vscode'; - -import { DecorationsService } from './service'; - -export class DecorationsProvider implements vscode.Disposable { - private readonly decorationType: vscode.TextEditorDecorationType; - - private readonly service = new DecorationsService(); - - private previousEditor: vscode.TextEditor | undefined; - - public constructor( - private readonly extensionPath: string, - private readonly getWorkspaceRoot: () => string, - ) { - this.decorationType = vscode.window.createTextEditorDecorationType({ - gutterIconPath: vscode.Uri.file( - path.join(extensionPath, 'media', 'frilvault-note-gutter.svg'), - ), - gutterIconSize: 'contain', - overviewRulerLane: vscode.OverviewRulerLane.Right, - overviewRulerColor: new vscode.ThemeColor('editorInfo.foreground'), - }); - } - - public async refresh(editor = vscode.window.activeTextEditor): Promise { - if (this.previousEditor && this.previousEditor !== editor) { - this.clear(this.previousEditor); - } - - if (!editor || editor.document.uri.scheme !== 'file') { - this.previousEditor = editor; - return; - } - - const workspaceRoot = this.getWorkspaceRoot(); - const notes = await this.service.listNotes({ - workspaceRoot, - sourceFile: editor.document.uri.fsPath, - }); - - const decorations = notes.flatMap((noteView) => { - if (noteView.note.anchor.type !== 'Line') { - return []; - } - - const line = Math.max(noteView.note.anchor.line - 1, 0); - if (line >= editor.document.lineCount) { - return []; - } - - return [ - { - range: editor.document.lineAt(line).range, - hoverMessage: new vscode.MarkdownString(noteView.note.content), - }, - ]; - }); - - editor.setDecorations(this.decorationType, decorations); - this.previousEditor = editor; - } - - public clear(editor = vscode.window.activeTextEditor): void { - editor?.setDecorations(this.decorationType, []); - } - - public dispose(): void { - this.decorationType.dispose(); - } -} diff --git a/apps/vscode-extension/src/features/decorations/service.ts b/apps/vscode-extension/src/features/decorations/service.ts deleted file mode 100644 index c49a6db..0000000 --- a/apps/vscode-extension/src/features/decorations/service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { NoteView } from '../../types'; -import { NotesPanelService } from '../notes-panel/service'; -import { getConfiguredCliPath } from '../add-note/cli'; - -export interface DecorationsServiceInput { - workspaceRoot: string; - sourceFile: string; -} - -export class DecorationsService { - private readonly notesPanelService = new NotesPanelService(); - - public async listNotes(input: DecorationsServiceInput): Promise { - return this.notesPanelService.listNotes({ - cliPath: getConfiguredCliPath(), - workspaceRoot: input.workspaceRoot, - sourceFile: input.sourceFile, - }); - } -} diff --git a/apps/vscode-extension/src/features/hover/hoverProvider.ts b/apps/vscode-extension/src/features/hover/hoverProvider.ts new file mode 100644 index 0000000..1b64e09 --- /dev/null +++ b/apps/vscode-extension/src/features/hover/hoverProvider.ts @@ -0,0 +1,46 @@ +import * as vscode from 'vscode'; + +import type { CliClient } from '../../core/cliClient'; +import { getRelativeFilePath } from '../../utils/file'; + +export class FrilVaultHoverProvider implements vscode.HoverProvider { + public constructor( + private readonly cliClient: CliClient, + private readonly getWorkspaceRoot: () => string, + ) {} + + public async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + if (document.uri.scheme !== 'file') { + return undefined; + } + + const notes = await this.cliClient.searchNotes({ + workspaceRoot: this.getWorkspaceRoot(), + sourceFile: getRelativeFilePath(this.getWorkspaceRoot(), document.uri.fsPath), + }); + + const matched = notes.filter((note) => { + if (note.note.anchor.type === 'Line') { + return (note.note.anchor.line ?? 1) - 1 === position.line; + } + + return (note.note.anchor.line_hint ?? 1) - 1 === position.line; + }); + + if (matched.length === 0) { + return undefined; + } + + const markdown = matched + .map((note) => { + const type = note.note.anchor.type; + return `**FrilVault Note**\n---\nType: ${type}\nContent: ${note.note.content}`; + }) + .join('\n\n'); + + return new vscode.Hover(new vscode.MarkdownString(markdown)); + } +} diff --git a/apps/vscode-extension/src/features/notes-panel/provider.ts b/apps/vscode-extension/src/features/notes-panel/provider.ts index 71434a9..666334c 100644 --- a/apps/vscode-extension/src/features/notes-panel/provider.ts +++ b/apps/vscode-extension/src/features/notes-panel/provider.ts @@ -1,49 +1,34 @@ -import * as path from 'node:path'; import * as vscode from 'vscode'; -import { getConfiguredCliPath } from '../add-note/cli'; -import type { NoteView } from '../../types'; +import { getRelativeFilePath } from '../../utils/file'; import { NotesPanelService } from './service'; +import { NotesFileGroupItem, NotesPanelItem } from './view'; -export class FrilVaultNoteTreeItem extends vscode.TreeItem { - public constructor( - public readonly noteView: NoteView, - public readonly sourceFile: string, - ) { - super(createTreeLabel(noteView), vscode.TreeItemCollapsibleState.None); +type TreeNode = NotesFileGroupItem | NotesPanelItem; - this.description = createDescription(noteView); - this.tooltip = createTooltip(noteView, sourceFile); - this.contextValue = 'frilvault.note'; - this.iconPath = new vscode.ThemeIcon('note'); - this.command = { - command: 'frilvault.notesPanel.openNote', - title: 'Open FrilVault Note', - arguments: [noteView], - }; - } -} - -export class FrilVaultNotesProvider - implements vscode.TreeDataProvider -{ +export class FrilVaultNotesProvider implements vscode.TreeDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); - private readonly service = new NotesPanelService(); - public readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - public constructor(private readonly getWorkspaceRoot: () => string) {} + public constructor( + private readonly service: NotesPanelService, + private readonly getWorkspaceRoot: () => string, + ) {} public refresh(): void { this.onDidChangeTreeDataEmitter.fire(); } - public getTreeItem(element: FrilVaultNoteTreeItem): vscode.TreeItem { + public getTreeItem(element: TreeNode): vscode.TreeItem { return element; } - public async getChildren(): Promise { + public async getChildren(element?: TreeNode): Promise { + if (element instanceof NotesFileGroupItem) { + return element.notes.map((note) => new NotesPanelItem(note, this.getWorkspaceRoot())); + } + const editor = vscode.window.activeTextEditor; if (!editor || editor.document.uri.scheme !== 'file') { @@ -51,54 +36,13 @@ export class FrilVaultNotesProvider } const workspaceRoot = this.getWorkspaceRoot(); - const sourceFile = editor.document.uri.fsPath; - - try { - const notes = await this.service.listNotes({ - cliPath: getConfiguredCliPath(), - workspaceRoot, - sourceFile, - }); - - return notes.map((noteView) => new FrilVaultNoteTreeItem(noteView, sourceFile)); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to load FrilVault notes.'; - - void vscode.window.showErrorMessage(`FrilVault: ${message}`); + const sourceFile = getRelativeFilePath(workspaceRoot, editor.document.uri.fsPath); + const notes = await this.service.listNotes(workspaceRoot, sourceFile); + if (notes.length === 0) { return []; } - } -} - -function createTreeLabel(noteView: NoteView): string { - return noteView.note.content.length > 60 - ? `${noteView.note.content.slice(0, 57)}...` - : noteView.note.content; -} -function createDescription(noteView: NoteView): string { - if (noteView.note.anchor.type === 'Line') { - return `L${noteView.note.anchor.line}`; + return [new NotesFileGroupItem(sourceFile, notes)]; } - - const symbolName = noteView.note.anchor.name; - const lineHint = - typeof noteView.note.anchor.line_hint === 'number' - ? `L${noteView.note.anchor.line_hint}` - : noteView.note.anchor.kind; - - return `${lineHint} ${symbolName}`; -} - -function createTooltip(noteView: NoteView, sourceFile: string): vscode.MarkdownString { - const anchorDescription = - noteView.note.anchor.type === 'Line' - ? `Line ${noteView.note.anchor.line}, Column ${noteView.note.anchor.column}` - : `${noteView.note.anchor.kind}: ${noteView.note.anchor.name}`; - - return new vscode.MarkdownString( - `**${path.basename(sourceFile)}**\n\n${anchorDescription}\n\n${noteView.note.content}`, - ); } diff --git a/apps/vscode-extension/src/features/notes-panel/service.ts b/apps/vscode-extension/src/features/notes-panel/service.ts index 987da6d..2a5e506 100644 --- a/apps/vscode-extension/src/features/notes-panel/service.ts +++ b/apps/vscode-extension/src/features/notes-panel/service.ts @@ -1,35 +1,10 @@ -import * as path from 'node:path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - +import type { CliClient } from '../../core/cliClient'; import type { NoteView } from '../../types'; -const execFileAsync = promisify(execFile); - -export interface NotesPanelServiceInput { - cliPath: string; - workspaceRoot: string; - sourceFile: string; -} - export class NotesPanelService { - public async listNotes(input: NotesPanelServiceInput): Promise { - const relativeFilePath = path.relative(input.workspaceRoot, input.sourceFile); - - if ( - relativeFilePath.length === 0 || - relativeFilePath.startsWith('..') || - path.isAbsolute(relativeFilePath) - ) { - throw new Error('The active file must be inside the current workspace.'); - } - - const { stdout } = await execFileAsync( - input.cliPath, - ['list', '--file', relativeFilePath, '--format', 'json'], - { cwd: input.workspaceRoot }, - ); + public constructor(private readonly cliClient: CliClient) {} - return JSON.parse(stdout) as NoteView[]; + public listNotes(workspaceRoot: string, sourceFile: string): Promise { + return this.cliClient.listNotes(workspaceRoot, sourceFile); } } diff --git a/apps/vscode-extension/src/features/notes-panel/view.ts b/apps/vscode-extension/src/features/notes-panel/view.ts new file mode 100644 index 0000000..2989875 --- /dev/null +++ b/apps/vscode-extension/src/features/notes-panel/view.ts @@ -0,0 +1,55 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import type { NoteView } from '../../types'; + +export class NotesFileGroupItem extends vscode.TreeItem { + public constructor( + public readonly sourceFile: string, + public readonly notes: NoteView[], + ) { + super(sourceFile, vscode.TreeItemCollapsibleState.Expanded); + this.description = `${notes.length} note${notes.length === 1 ? '' : 's'}`; + this.iconPath = new vscode.ThemeIcon('file'); + this.contextValue = 'frilvault.notesFileGroup'; + } +} + +export class NotesPanelItem extends vscode.TreeItem { + public constructor( + public readonly noteView: NoteView, + public readonly workspaceRoot: string, + ) { + super(createPreview(noteView), vscode.TreeItemCollapsibleState.None); + + this.description = createDescription(noteView); + this.tooltip = new vscode.MarkdownString( + `**${path.basename(noteView.source_file)}**\n\n${noteView.note.content}`, + ); + this.contextValue = 'frilvault.note'; + this.iconPath = new vscode.ThemeIcon('note'); + this.command = { + command: 'frilvault.notesPanel.openNote', + title: 'Open FrilVault Note', + arguments: [noteView], + }; + } +} + +function createPreview(noteView: NoteView): string { + return noteView.note.content.length > 60 + ? `${noteView.note.content.slice(0, 57)}...` + : noteView.note.content; +} + +function createDescription(noteView: NoteView): string { + if (noteView.note.anchor.type === 'Line') { + return `L${noteView.note.anchor.line ?? 1}`; + } + + const lineHint = + typeof noteView.note.anchor.line_hint === 'number' + ? `L${noteView.note.anchor.line_hint}` + : 'Symbol'; + return `${lineHint} ${noteView.note.anchor.name ?? ''}`.trim(); +} diff --git a/apps/vscode-extension/src/features/search/command.ts b/apps/vscode-extension/src/features/search/command.ts new file mode 100644 index 0000000..142becc --- /dev/null +++ b/apps/vscode-extension/src/features/search/command.ts @@ -0,0 +1,48 @@ +import * as vscode from 'vscode'; + +import type { CliClient } from '../../core/cliClient'; +import { revealNote } from '../../utils/file'; + +export function createSearchCommand( + cliClient: CliClient, + getWorkspaceRoot: () => string, +): () => Promise { + return async () => { + const keyword = await vscode.window.showInputBox({ + prompt: 'Search FrilVault notes', + ignoreFocusOut: true, + }); + + if (!keyword || keyword.trim().length === 0) { + return; + } + + const workspaceRoot = getWorkspaceRoot(); + const results = await cliClient.searchNotes({ + workspaceRoot, + keyword: keyword.trim(), + }); + + if (results.length === 0) { + await vscode.window.showInformationMessage(`No notes found for "${keyword}".`); + return; + } + + const picked = await vscode.window.showQuickPick( + results.map((note) => ({ + label: note.note.content, + description: note.source_file, + detail: + note.note.anchor.type === 'Line' + ? `Line ${note.note.anchor.line ?? 1}, Column ${note.note.anchor.column ?? 1}` + : `${note.note.anchor.name ?? 'Symbol'} `, + note, + })), + { placeHolder: `Found ${results.length} note(s)` }, + ); + + if (picked) { + await revealNote(picked.note, workspaceRoot); + } + }; +} diff --git a/apps/vscode-extension/src/features/workspace/health.ts b/apps/vscode-extension/src/features/workspace/health.ts new file mode 100644 index 0000000..95dae84 --- /dev/null +++ b/apps/vscode-extension/src/features/workspace/health.ts @@ -0,0 +1,54 @@ +import * as vscode from 'vscode'; + +import type { NodeBridge } from '../../core/nodeBridge'; + +export function createShowHealthCommand( + nodeBridge: NodeBridge, + getWorkspaceRoot: () => string, +): () => Promise { + return async () => { + const workspaceRoot = getWorkspaceRoot(); + const health = nodeBridge.workspaceHealth(workspaceRoot); + const suggestions = nodeBridge.repairSuggestions(workspaceRoot); + const channel = vscode.window.createOutputChannel('FrilVault Health'); + + channel.clear(); + + if (health.missing_source_files.length === 0) { + channel.appendLine('No missing source files.'); + } else { + channel.appendLine('Missing source files:'); + for (const file of health.missing_source_files) { + channel.appendLine(file); + } + } + + if (suggestions.length > 0) { + channel.appendLine(''); + channel.appendLine('Repair suggestions:'); + for (const suggestion of suggestions) { + channel.appendLine( + `${suggestion.missing_file} -> ${ + suggestion.candidates.length > 0 ? suggestion.candidates.join(', ') : 'no candidates' + }`, + ); + } + } + + channel.show(true); + }; +} + +export function createApplyRepairsCommand( + nodeBridge: NodeBridge, + getWorkspaceRoot: () => string, + refreshNotesPanel: () => void, + refreshDecorations: () => Promise, +): () => Promise { + return async () => { + const repaired = nodeBridge.applyRepairs(getWorkspaceRoot()); + refreshNotesPanel(); + await refreshDecorations(); + await vscode.window.showInformationMessage(`FrilVault repaired ${repaired} file(s).`); + }; +} diff --git a/apps/vscode-extension/src/features/workspace/stats.ts b/apps/vscode-extension/src/features/workspace/stats.ts new file mode 100644 index 0000000..2722724 --- /dev/null +++ b/apps/vscode-extension/src/features/workspace/stats.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +import type { NodeBridge } from '../../core/nodeBridge'; + +export function createShowStatsCommand( + nodeBridge: NodeBridge, + getWorkspaceRoot: () => string, +): () => Promise { + return async () => { + const stats = nodeBridge.workspaceStats(getWorkspaceRoot()); + const channel = vscode.window.createOutputChannel('FrilVault Stats'); + channel.clear(); + channel.appendLine(`files: ${stats.file_count}`); + channel.appendLine(`notes: ${stats.total_notes}`); + channel.appendLine(`existing files: ${stats.existing_files}`); + channel.appendLine(`missing files: ${stats.missing_files}`); + channel.appendLine(`line notes: ${stats.line_notes}`); + channel.appendLine(`symbol notes: ${stats.symbol_notes}`); + channel.show(true); + }; +} diff --git a/apps/vscode-extension/src/test/extension.test.ts b/apps/vscode-extension/src/test/extension.test.ts index e9fa639..4b88afc 100644 --- a/apps/vscode-extension/src/test/extension.test.ts +++ b/apps/vscode-extension/src/test/extension.test.ts @@ -6,7 +6,9 @@ import * as path from 'node:path'; import { suite, test, teardown } from 'mocha'; import * as vscode from 'vscode'; +import { CliClient } from '../core/cliClient'; import { createAddNoteCommand } from '../features/add-note/command'; +import { AddNoteService } from '../features/add-note/service'; import { FrilVaultNotesProvider } from '../features/notes-panel/provider'; import { NotesPanelService } from '../features/notes-panel/service'; @@ -46,12 +48,9 @@ suite('Extension Test Suite', () => { createLineNoteView('src/sample.ts', 3, 5, 'service note'), ]); - const service = new NotesPanelService(); - const notes = await service.listNotes({ - cliPath: workspace.cliPath, - workspaceRoot: workspace.root, - sourceFile: workspace.sourceFile, - }); + const cliClient = new CliClient(() => workspace.cliPath); + const service = new NotesPanelService(cliClient); + const notes = await service.listNotes(workspace.root, path.join('src', 'sample.ts')); assert.strictEqual(notes.length, 1); assert.strictEqual(notes[0]?.note.content, 'service note'); @@ -68,20 +67,26 @@ suite('Extension Test Suite', () => { await configureExtension(workspace); await openFile(workspace.sourceFile); - const provider = new FrilVaultNotesProvider(() => workspace.root); + const cliClient = new CliClient(() => workspace.cliPath); + const provider = new FrilVaultNotesProvider(new NotesPanelService(cliClient), () => workspace.root); const firstChildren = await provider.getChildren(); assert.strictEqual(firstChildren.length, 1); - assert.strictEqual(firstChildren[0]?.label, 'first file note'); - assert.strictEqual(firstChildren[0]?.description, 'L7'); + assert.strictEqual(firstChildren[0]?.label, path.join('src', 'sample.ts')); + assert.strictEqual(firstChildren[0]?.description, '1 note'); + const firstNotes = await provider.getChildren(firstChildren[0]); + assert.strictEqual(firstNotes[0]?.label, 'first file note'); + assert.strictEqual(firstNotes[0]?.description, 'L7'); await openFile(workspace.secondSourceFile); const secondChildren = await provider.getChildren(); assert.strictEqual(secondChildren.length, 1); - assert.strictEqual(secondChildren[0]?.label, 'second file note'); - assert.strictEqual(secondChildren[0]?.description, 'L2'); + assert.strictEqual(secondChildren[0]?.label, path.join('src', 'other.ts')); + const secondNotes = await provider.getChildren(secondChildren[0]); + assert.strictEqual(secondNotes[0]?.label, 'second file note'); + assert.strictEqual(secondNotes[0]?.description, 'L2'); }); test('Add Note command executes flvt add with relative file path and refreshes', async () => { @@ -95,18 +100,16 @@ suite('Extension Test Suite', () => { let decorationRefreshCount = 0; let successMessage = ''; let errorMessage = ''; + const cliClient = new CliClient(() => workspace.cliPath); const command = createAddNoteCommand({ getWorkspaceRoot: () => workspace.root, - noteTreeDataProvider: { - refresh() { + service: new AddNoteService(cliClient), + refreshNotesPanel: () => { treeRefreshCount += 1; - }, }, - decorationsProvider: { - async refresh() { + refreshDecorations: async () => { decorationRefreshCount += 1; - }, }, promptNoteContent: async () => 'added from command test', showInformationMessage: async (message) => { diff --git a/apps/vscode-extension/src/types.ts b/apps/vscode-extension/src/types.ts deleted file mode 100644 index 668e664..0000000 --- a/apps/vscode-extension/src/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type NoteAnchor = - | { - type: 'Line'; - line: number; - column: number; - } - | { - type: 'Symbol'; - name: string; - kind: string; - signature?: string; - line_hint?: number; - }; - -export interface FrilVaultNote { - id: string; - anchor: NoteAnchor; - content: string; - created_at: string; - updated_at: string; -} - -export interface NoteView { - source_file: string; - note: FrilVaultNote; -} - -export interface WorkspaceStats { - file_count: number; - total_notes: number; - existing_files: number; - missing_files: number; - line_notes: number; - symbol_notes: number; -} - -export interface WorkspaceHealth { - missing_source_files: string[]; -} - -export interface RepairSuggestion { - missing_file: string; - candidates: string[]; -} - -export interface MutationResult { - note: FrilVaultNote | null; -} diff --git a/apps/vscode-extension/src/types/index.ts b/apps/vscode-extension/src/types/index.ts new file mode 100644 index 0000000..14ba824 --- /dev/null +++ b/apps/vscode-extension/src/types/index.ts @@ -0,0 +1,42 @@ +export type NoteAnchor = { + type: 'Line' | 'Symbol'; + line?: number; + column?: number; + name?: string; + kind?: string; + signature?: string; + line_hint?: number; +}; + +export type NoteView = { + source_file: string; + note: { + id: string; + content: string; + anchor: NoteAnchor; + created_at?: string; + updated_at?: string; + }; +}; + +export interface WorkspaceStats { + file_count: number; + total_notes: number; + existing_files: number; + missing_files: number; + line_notes: number; + symbol_notes: number; +} + +export interface WorkspaceHealth { + missing_source_files: string[]; +} + +export interface RepairSuggestion { + missing_file: string; + candidates: string[]; +} + +export interface MutationResult { + note: NoteView['note'] | null; +} diff --git a/apps/vscode-extension/src/utils/file.ts b/apps/vscode-extension/src/utils/file.ts new file mode 100644 index 0000000..61830ad --- /dev/null +++ b/apps/vscode-extension/src/utils/file.ts @@ -0,0 +1,64 @@ +import * as path from 'node:path'; + +import * as vscode from 'vscode'; + +import type { NoteView } from '../types'; + +export function getWorkspaceRoot(): string { + const configured = vscode.workspace + .getConfiguration('frilvault') + .get('workspaceRoot', '') + .trim(); + + if (configured.length > 0) { + return configured; + } + + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + throw new Error('FrilVault requires an open workspace folder.'); + } + + return folder.uri.fsPath; +} + +export function getActiveEditorOrThrow(): vscode.TextEditor { + const editor = vscode.window.activeTextEditor; + + if (!editor) { + throw new Error('No active editor.'); + } + + if (editor.document.uri.scheme !== 'file') { + throw new Error('FrilVault only supports files on disk.'); + } + + return editor; +} + +export function getRelativeFilePath(workspaceRoot: string, sourceFile: string): string { + const relative = path.relative(workspaceRoot, sourceFile); + + if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('The active file must be inside the current workspace.'); + } + + return relative; +} + +export async function revealNote(note: NoteView, workspaceRoot: string): Promise { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.file(path.join(workspaceRoot, note.source_file)), + ); + const editor = await vscode.window.showTextDocument(document); + const line = + note.note.anchor.type === 'Line' + ? Math.max((note.note.anchor.line ?? 1) - 1, 0) + : Math.max((note.note.anchor.line_hint ?? 1) - 1, 0); + const column = + note.note.anchor.type === 'Line' ? Math.max((note.note.anchor.column ?? 1) - 1, 0) : 0; + const position = new vscode.Position(line, column); + + editor.selection = new vscode.Selection(position, position); + editor.revealRange(new vscode.Range(position, position)); +} diff --git a/apps/vscode-extension/src/utils/parser.ts b/apps/vscode-extension/src/utils/parser.ts new file mode 100644 index 0000000..40fae6e --- /dev/null +++ b/apps/vscode-extension/src/utils/parser.ts @@ -0,0 +1,3 @@ +export function parseJson(value: string): T { + return JSON.parse(value) as T; +} diff --git a/crates/frilvault-core/src/cache/vault_context.rs b/crates/frilvault-core/src/cache/vault_context.rs index 380d1ea..ebfa7fa 100644 --- a/crates/frilvault-core/src/cache/vault_context.rs +++ b/crates/frilvault-core/src/cache/vault_context.rs @@ -1,11 +1,41 @@ -// use crate::{NoteCache, WorkspaceIndexRepository, WorkspaceRepository, YamlNoteRepository}; +use crate::{FrilVaultResult, NoteCache, NoteFile, WorkspaceIndexRepository, YamlNoteRepository}; pub struct VaultContext { - // note_repository: YamlNoteRepository, + pub note_repository: YamlNoteRepository, + pub workspace_index_repository: WorkspaceIndexRepository, + pub cache: NoteCache, +} + +impl VaultContext { + pub fn new( + note_repository: YamlNoteRepository, + workspace_index_repository: WorkspaceIndexRepository, + ) -> Self { + Self { + note_repository, + workspace_index_repository, + cache: NoteCache::default(), + } + } + + pub fn load_notes(&mut self, source_file: &std::path::Path) -> FrilVaultResult { + let key = source_file.to_path_buf(); + + // 1. CACHE HIT + if let Some(cached) = self.cache.get(&key) { + return Ok(cached.clone()); + } + + // 2. REPOSITORY LOAD + let note_file = self.note_repository.load_by_source_file(source_file)?; - // workspace_repository: WorkspaceRepository, + // 3. CACHE STORE + self.cache.insert(key, note_file.clone()); - // workspace_index_repository: WorkspaceIndexRepository, + Ok(note_file) + } - // cache: NoteCache, + pub fn invalidate_notes(&mut self, source_file: &std::path::Path) { + self.cache.invalidate(&source_file.to_path_buf()); + } } diff --git a/crates/frilvault-core/src/note/dto/mod.rs b/crates/frilvault-core/src/note/dto/mod.rs new file mode 100644 index 0000000..efa3612 --- /dev/null +++ b/crates/frilvault-core/src/note/dto/mod.rs @@ -0,0 +1 @@ +pub mod note_view; diff --git a/crates/frilvault-core/src/note/note_view.rs b/crates/frilvault-core/src/note/dto/note_view.rs similarity index 100% rename from crates/frilvault-core/src/note/note_view.rs rename to crates/frilvault-core/src/note/dto/note_view.rs diff --git a/crates/frilvault-core/src/note/mod.rs b/crates/frilvault-core/src/note/mod.rs index 0bed352..01003c6 100644 --- a/crates/frilvault-core/src/note/mod.rs +++ b/crates/frilvault-core/src/note/mod.rs @@ -1,9 +1,9 @@ +mod dto; mod entity; -mod note_view; mod repository; mod service; +pub use dto::*; pub use entity::*; -pub use note_view::*; pub use repository::*; pub use service::*; diff --git a/crates/frilvault-core/src/note/service.rs b/crates/frilvault-core/src/note/service.rs index 479d466..1cf0bfb 100644 --- a/crates/frilvault-core/src/note/service.rs +++ b/crates/frilvault-core/src/note/service.rs @@ -4,63 +4,73 @@ use chrono::Utc; use uuid::Uuid; use crate::{ - FrilVaultError, FrilVaultResult, NoteAnchor, NoteView, + FrilVaultError, FrilVaultResult, NoteAnchor, VaultContext, note::{AddNoteInput, Note}, + note_view::NoteView, }; -use crate::note::NoteRepository; - -pub struct NoteService -where - R: NoteRepository, -{ - repository: R, +pub struct NoteService { + pub vault_context: VaultContext, } -impl NoteService -where - R: NoteRepository, -{ - pub fn new(repository: R) -> Self { - Self { repository } +impl NoteService { + pub fn new(vault_context: VaultContext) -> Self { + Self { vault_context } } - fn load_notes(&self, source_file: impl AsRef) -> FrilVaultResult> { - Ok(self - .repository - .load_by_source_file(source_file.as_ref())? - .notes) + fn load_notes(&mut self, source_file: impl AsRef) -> FrilVaultResult> { + Ok(self.vault_context.load_notes(source_file.as_ref())?.notes) } - fn save_notes(&self, source_file: impl AsRef, notes: Vec) -> FrilVaultResult<()> { - self.repository.replace_notes(source_file.as_ref(), notes) + fn save_notes( + &mut self, + source_file: impl AsRef, + notes: Vec, + ) -> FrilVaultResult<()> { + let source_file = source_file.as_ref(); + + self.vault_context + .note_repository + .replace_notes(source_file, notes)?; + + self.vault_context.invalidate_notes(source_file); + + Ok(()) } - pub fn add_note(&self, input: AddNoteInput) -> FrilVaultResult { + pub fn add_note(&mut self, input: AddNoteInput) -> FrilVaultResult { let source_file = input.source_file.clone(); let note = Note::new(input); - self.repository.append_note(&source_file, ¬e)?; + self.vault_context + .note_repository + .append_note(&source_file, ¬e)?; + + self.vault_context.invalidate_notes(&source_file); Ok(note) } - pub fn list_notes(&self, source_file: impl AsRef) -> FrilVaultResult> { + pub fn list_notes(&mut self, source_file: impl AsRef) -> FrilVaultResult> { let source_file = source_file.as_ref(); - let notes = self.load_notes(source_file)?; + let notes = self.vault_context.load_notes(source_file)?; Ok(notes + .notes .into_iter() .map(|note| NoteView { source_file: source_file.to_path_buf(), - note, }) .collect()) } - pub fn delete_note(&self, source_file: impl AsRef, note_id: Uuid) -> FrilVaultResult<()> { + pub fn delete_note( + &mut self, + source_file: impl AsRef, + note_id: Uuid, + ) -> FrilVaultResult<()> { let source_file = source_file.as_ref(); let mut notes = self.load_notes(source_file)?; @@ -73,13 +83,15 @@ where return Err(FrilVaultError::NoteNotFound(note_id)); } - self.repository.replace_notes(source_file, notes)?; + self.save_notes(source_file, notes)?; + + self.vault_context.invalidate_notes(source_file); Ok(()) } pub fn update_note( - &self, + &mut self, source_file: impl AsRef, note_id: Uuid, content: String, @@ -98,11 +110,13 @@ where self.save_notes(source_file, notes)?; + self.vault_context.invalidate_notes(source_file); + Ok(()) } - pub fn search_notes(&self, keyword: &str) -> FrilVaultResult> { - let records = self.repository.list_all_note_files()?; + pub fn search_notes(&mut self, keyword: &str) -> FrilVaultResult> { + let records = self.vault_context.note_repository.list_all_note_files()?; let keyword = keyword.to_lowercase(); @@ -114,7 +128,6 @@ where let symbol_match = match ¬e.anchor { NoteAnchor::Symbol(anchor) => anchor.name.to_lowercase().contains(&keyword), - _ => false, }; @@ -129,4 +142,27 @@ where Ok(results) } + + pub fn search_by_symbol(&mut self, symbol: &str) -> FrilVaultResult> { + let symbol = symbol.to_lowercase(); + + let records = self.vault_context.note_repository.list_all_note_files()?; + + let mut results = Vec::new(); + + for record in records { + for note in record.note_file.notes { + if let NoteAnchor::Symbol(anchor) = ¬e.anchor + && anchor.name.to_lowercase().contains(&symbol) + { + results.push(NoteView { + source_file: record.source_file.clone(), + note, + }); + } + } + } + + Ok(results) + } } diff --git a/crates/frilvault-core/src/tests/note_service_test.rs b/crates/frilvault-core/src/tests/note_service_test.rs index c72a6ed..a001d45 100644 --- a/crates/frilvault-core/src/tests/note_service_test.rs +++ b/crates/frilvault-core/src/tests/note_service_test.rs @@ -1,9 +1,20 @@ use crate::{ AddNoteInput, LineAnchor, NoteAnchor, NoteService, PathResolver, SymbolAnchor, SymbolKind, - YamlNoteRepository, constants::NOTE_FILE_EXTENSION, + VaultContext, WorkspaceIndexRepository, YamlNoteRepository, constants::NOTE_FILE_EXTENSION, }; -use std::fs; +use std::{fs, path::Path}; + +pub fn return_service(workspace_root: &Path) -> NoteService { + let resolver = PathResolver::new(workspace_root); + + let note_repository = YamlNoteRepository::new(resolver.clone()); + let index_repository = WorkspaceIndexRepository::new(resolver); + + let vault_context = VaultContext::new(note_repository, index_repository); + + NoteService::new(vault_context) +} #[test] fn add_line_type_note_creates_yaml_file() { @@ -12,9 +23,7 @@ fn add_line_type_note_creates_yaml_file() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - let repository = YamlNoteRepository::new(resolver); - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); let input = AddNoteInput { source_file: "src/main.rs".into(), @@ -48,9 +57,7 @@ fn add_symbol_type_note_creates_yaml_file() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - let repository = YamlNoteRepository::new(resolver); - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); let input = AddNoteInput { source_file: "src/main.rs".into(), @@ -100,9 +107,7 @@ fn load_notes_from_existing_yaml() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - let repository = YamlNoteRepository::new(resolver); - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); service .add_note(AddNoteInput { @@ -127,9 +132,7 @@ fn add_note_and_load_note() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - let repository = YamlNoteRepository::new(resolver); - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); service .add_note(AddNoteInput { @@ -166,11 +169,7 @@ fn delete_note_removes_note() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let repository = YamlNoteRepository::new(resolver); - - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); let note = service .add_note(AddNoteInput { @@ -199,11 +198,7 @@ fn update_note_changes_content() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let repository = YamlNoteRepository::new(resolver); - - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); let note = service .add_note(AddNoteInput { @@ -236,11 +231,7 @@ fn search_notes_finds_matching_notes() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let repository = YamlNoteRepository::new(resolver); - - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); service .add_note(AddNoteInput { @@ -300,11 +291,7 @@ fn search_finds_symbol_anchor() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let repository = YamlNoteRepository::new(resolver); - - let service = NoteService::new(repository); + let mut service = return_service(&workspace_root); service .add_note(AddNoteInput { diff --git a/crates/frilvault-core/src/tests/workspace_index_repository_test.rs b/crates/frilvault-core/src/tests/workspace_index_repository_test.rs index 5a06cc5..200ea37 100644 --- a/crates/frilvault-core/src/tests/workspace_index_repository_test.rs +++ b/crates/frilvault-core/src/tests/workspace_index_repository_test.rs @@ -2,9 +2,34 @@ use std::fs; use crate::{ AddNoteInput, LineAnchor, NoteAnchor, NoteService, PathResolver, SymbolAnchor, SymbolKind, - WorkspaceIndex, WorkspaceIndexRepository, WorkspaceService, YamlNoteRepository, + VaultContext, WorkspaceIndex, WorkspaceIndexRepository, WorkspaceRepository, WorkspaceService, + YamlNoteRepository, }; +fn create_vault_context(workspace_root: &std::path::Path) -> VaultContext { + let resolver = PathResolver::new(workspace_root); + let workspace_repository = WorkspaceRepository::new(resolver.clone()); + workspace_repository.create_if_missing().unwrap(); + + let note_repository = YamlNoteRepository::new(resolver.clone()); + let index_repository = WorkspaceIndexRepository::new(resolver); + index_repository.create_if_missing().unwrap(); + + VaultContext::new(note_repository, index_repository) +} + +fn create_note_service(workspace_root: &std::path::Path) -> NoteService { + NoteService::new(create_vault_context(workspace_root)) +} + +fn create_workspace_service(workspace_root: &std::path::Path) -> WorkspaceService { + let vault_context = create_vault_context(workspace_root); + let resolver = PathResolver::new(workspace_root); + let repository = WorkspaceIndexRepository::new(resolver); + + WorkspaceService::new(vault_context, repository) +} + #[test] fn load_returns_default_index_when_missing() { let workspace_root = @@ -82,8 +107,7 @@ fn rebuild_creates_index_from_note_files() { let resolver = PathResolver::new(&workspace_root); - let note_repository = YamlNoteRepository::new(resolver.clone()); - let service = NoteService::new(note_repository); + let mut service = create_note_service(&workspace_root); service .add_note(AddNoteInput { @@ -146,8 +170,7 @@ fn rebuild_marks_missing_files_as_not_existing() { let resolver = PathResolver::new(&workspace_root); - let note_repository = YamlNoteRepository::new(resolver.clone()); - let service = NoteService::new(note_repository); + let mut service = create_note_service(&workspace_root); service .add_note(AddNoteInput { @@ -175,11 +198,7 @@ fn health_check_detects_missing_files_from_note_repository() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let note_repository = YamlNoteRepository::new(resolver.clone()); - - let service = NoteService::new(note_repository.clone()); + let mut service = create_note_service(&workspace_root); service .add_note(AddNoteInput { @@ -191,9 +210,7 @@ fn health_check_detects_missing_files_from_note_repository() { }) .unwrap(); - let repository = WorkspaceIndexRepository::new(resolver); - - let workspace_service = WorkspaceService::new(note_repository, repository); + let mut workspace_service = create_workspace_service(&workspace_root); let health = workspace_service.health_check().unwrap(); @@ -211,15 +228,9 @@ fn stats_counts_line_and_symbol_notes() { fs::create_dir_all(&workspace_root).unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let repository = WorkspaceIndexRepository::new(resolver.clone()); + let mut service = create_note_service(&workspace_root); - let note_repository = YamlNoteRepository::new(resolver); - - let service = NoteService::new(note_repository.clone()); - - let workspace_service = WorkspaceService::new(note_repository, repository); + let mut workspace_service = create_workspace_service(&workspace_root); service .add_note(AddNoteInput { @@ -275,11 +286,7 @@ fn repair_suggests_matching_file_names() { fs::write(workspace_root.join("src/core/lib.rs"), "").unwrap(); - let resolver = PathResolver::new(&workspace_root); - - let note_repository = YamlNoteRepository::new(resolver.clone()); - - let service = NoteService::new(note_repository.clone()); + let mut service = create_note_service(&workspace_root); service .add_note(AddNoteInput { @@ -291,9 +298,7 @@ fn repair_suggests_matching_file_names() { }) .unwrap(); - let repository = WorkspaceIndexRepository::new(resolver); - - let workspace_service = WorkspaceService::new(note_repository, repository); + let mut workspace_service = create_workspace_service(&workspace_root); let suggestions = workspace_service.repair_suggestions().unwrap(); @@ -317,9 +322,7 @@ fn apply_repairs_moves_note_file() { let resolver = PathResolver::new(&workspace_root); - let note_repository = YamlNoteRepository::new(resolver.clone()); - - let service = NoteService::new(note_repository.clone()); + let mut service = create_note_service(&workspace_root); service .add_note(AddNoteInput { @@ -335,9 +338,7 @@ fn apply_repairs_moves_note_file() { assert!(old_note_path.exists()); - let repository = WorkspaceIndexRepository::new(resolver.clone()); - - let workspace_service = WorkspaceService::new(note_repository, repository); + let mut workspace_service = create_workspace_service(&workspace_root); let repaired = workspace_service.apply_repairs().unwrap(); diff --git a/crates/frilvault-core/src/tests/yaml_repository_test.rs b/crates/frilvault-core/src/tests/yaml_repository_test.rs index 4284b8c..6625e8a 100644 --- a/crates/frilvault-core/src/tests/yaml_repository_test.rs +++ b/crates/frilvault-core/src/tests/yaml_repository_test.rs @@ -1,12 +1,22 @@ -use crate::{AddNoteInput, LineAnchor, NoteAnchor, NoteService, PathResolver, YamlNoteRepository}; +use crate::{ + AddNoteInput, LineAnchor, NoteAnchor, NoteService, PathResolver, VaultContext, + WorkspaceIndexRepository, WorkspaceRepository, YamlNoteRepository, +}; use std::fs; -fn create_service(workspace_root: &std::path::Path) -> NoteService { +fn create_service(workspace_root: &std::path::Path) -> NoteService { let resolver = PathResolver::new(workspace_root); + let workspace_repository = WorkspaceRepository::new(resolver.clone()); + workspace_repository.create_if_missing().unwrap(); + + let index_repository = WorkspaceIndexRepository::new(resolver.clone()); + index_repository.create_if_missing().unwrap(); + let repository = YamlNoteRepository::new(resolver); + let vault_context = VaultContext::new(repository, index_repository); - NoteService::new(repository) + NoteService::new(vault_context) } fn create_repository(workspace_root: &std::path::Path) -> YamlNoteRepository { @@ -22,7 +32,7 @@ fn list_all_note_files_returns_all_note_files() { fs::create_dir_all(&workspace_root).unwrap(); - let service = create_service(&workspace_root); + let mut service = create_service(&workspace_root); service .add_note(AddNoteInput { diff --git a/crates/frilvault-core/src/workspace/service/workspace_service.rs b/crates/frilvault-core/src/workspace/service/workspace_service.rs index 8c22434..32a184c 100644 --- a/crates/frilvault-core/src/workspace/service/workspace_service.rs +++ b/crates/frilvault-core/src/workspace/service/workspace_service.rs @@ -1,30 +1,27 @@ use std::path::Path; use crate::{ - FrilVaultResult, NoteAnchor, RepairSuggestion, WorkspaceHealth, WorkspaceIndexRepository, - WorkspaceStats, YamlNoteRepository, + FrilVaultResult, NoteAnchor, RepairSuggestion, VaultContext, WorkspaceHealth, + WorkspaceIndexRepository, WorkspaceStats, }; pub struct WorkspaceService { - note_repository: YamlNoteRepository, - index_repository: WorkspaceIndexRepository, + pub vault_context: VaultContext, + pub index_repository: WorkspaceIndexRepository, } impl WorkspaceService { - pub fn new( - note_repository: YamlNoteRepository, - index_repository: WorkspaceIndexRepository, - ) -> Self { + pub fn new(vault_context: VaultContext, index_repository: WorkspaceIndexRepository) -> Self { Self { - note_repository, + vault_context, index_repository, } } - pub fn stats(&self) -> FrilVaultResult { + pub fn stats(&mut self) -> FrilVaultResult { let index = self.index_repository.rebuild()?; - let records = self.note_repository.list_all_note_files()?; + let records = self.vault_context.note_repository.list_all_note_files()?; let mut stats = WorkspaceStats { file_count: index.files.len(), @@ -44,13 +41,8 @@ impl WorkspaceService { for record in records { for note in record.note_file.notes { match note.anchor { - NoteAnchor::Line(_) => { - stats.line_notes += 1; - } - - NoteAnchor::Symbol(_) => { - stats.symbol_notes += 1; - } + NoteAnchor::Line(_) => stats.line_notes += 1, + NoteAnchor::Symbol(_) => stats.symbol_notes += 1, } } } @@ -58,10 +50,9 @@ impl WorkspaceService { Ok(stats) } - pub fn health_check(&self) -> FrilVaultResult { + pub fn health_check(&mut self) -> FrilVaultResult { let index = self.index_repository.rebuild()?; - // TODO: - // Use cached index after index invalidation support is implemented. + let mut health = WorkspaceHealth::default(); for file in index.files { @@ -101,7 +92,6 @@ impl WorkspaceService { } self.collect_workspace_files(&path, files)?; - continue; } @@ -115,9 +105,8 @@ impl WorkspaceService { Ok(()) } - pub fn repair_suggestions(&self) -> FrilVaultResult> { + pub fn repair_suggestions(&mut self) -> FrilVaultResult> { let health = self.health_check()?; - let workspace_files = self.scan_workspace_files()?; let mut suggestions = Vec::new(); @@ -151,7 +140,7 @@ impl WorkspaceService { Ok(suggestions) } - pub fn apply_repairs(&self) -> FrilVaultResult { + pub fn apply_repairs(&mut self) -> FrilVaultResult { let suggestions = self.repair_suggestions()?; let mut repaired = 0; @@ -170,9 +159,15 @@ impl WorkspaceService { } fn move_note_file(&self, source_file: &str, target_file: &str) -> FrilVaultResult<()> { - let source_note = self.note_repository.resolve_note_path(source_file); - - let target_note = self.note_repository.resolve_note_path(target_file); + let source_note = self + .vault_context + .note_repository + .resolve_note_path(source_file); + + let target_note = self + .vault_context + .note_repository + .resolve_note_path(target_file); if let Some(parent) = target_note.parent() { std::fs::create_dir_all(parent)?; diff --git a/crates/frilvault-node/src/lib.rs b/crates/frilvault-node/src/lib.rs index 45ae049..77b92af 100644 --- a/crates/frilvault-node/src/lib.rs +++ b/crates/frilvault-node/src/lib.rs @@ -2,10 +2,10 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use frilvault_core::{ - AddNoteInput, FrilVaultError, LineAnchor, Note, NoteAnchor, NoteService, NoteView, - PathResolver, RepairSuggestion, SymbolAnchor, SymbolKind, WorkspaceHealth, + AddNoteInput, FrilVaultError, LineAnchor, Note, NoteAnchor, NoteService, PathResolver, + RepairSuggestion, SymbolAnchor, SymbolKind, VaultContext, WorkspaceHealth, WorkspaceIndexRepository, WorkspaceRepository, WorkspaceService, WorkspaceStats, - YamlNoteRepository, + YamlNoteRepository, note_view::NoteView, }; use napi::bindgen_prelude::Error; use napi_derive::napi; @@ -76,20 +76,30 @@ fn parse_uuid(note_id: &str) -> napi::Result { Uuid::parse_str(note_id).map_err(|error| Error::from_reason(error.to_string())) } -fn note_service(workspace_root: &str) -> napi::Result> { +fn serialize_json(value: &T) -> napi::Result { + serde_json::to_string(value).map_err(|error| Error::from_reason(error.to_string())) +} + +fn normalize_source_file(workspace_root: &str, source_file: &str) -> napi::Result { let resolver = PathResolver::new(workspace_root); - let workspace_repository = WorkspaceRepository::new(resolver.clone()); - workspace_repository - .create_if_missing() - .map_err(napi_error)?; + let path = PathBuf::from(source_file); - let index_repository = WorkspaceIndexRepository::new(resolver.clone()); - index_repository.create_if_missing().map_err(napi_error)?; + if path.is_absolute() { + return resolver.to_workspace_relative(path).map_err(napi_error); + } + + if Path::new(source_file).starts_with(resolver.workspace_root()) { + return resolver + .to_workspace_relative(source_file) + .map_err(napi_error); + } - Ok(NoteService::new(YamlNoteRepository::new(resolver))) + Ok(path) } -fn workspace_service(workspace_root: &str) -> napi::Result { +fn build_vault_context( + workspace_root: &str, +) -> napi::Result<(VaultContext, WorkspaceIndexRepository)> { let resolver = PathResolver::new(workspace_root); let workspace_repository = WorkspaceRepository::new(resolver.clone()); workspace_repository @@ -100,39 +110,34 @@ fn workspace_service(workspace_root: &str) -> napi::Result { index_repository.create_if_missing().map_err(napi_error)?; let note_repository = YamlNoteRepository::new(resolver); + let vault_context = VaultContext::new(note_repository, index_repository.clone()); - Ok(WorkspaceService::new(note_repository, index_repository)) + Ok((vault_context, index_repository)) } -fn normalize_source_file(workspace_root: &str, source_file: &str) -> napi::Result { - let resolver = PathResolver::new(workspace_root); - let path = PathBuf::from(source_file); - - if path.is_absolute() { - return resolver.to_workspace_relative(path).map_err(napi_error); - } - - if Path::new(source_file).starts_with(resolver.workspace_root()) { - return resolver - .to_workspace_relative(source_file) - .map_err(napi_error); - } +fn note_service(workspace_root: &str) -> napi::Result { + let (vault_context, _) = build_vault_context(workspace_root)?; - Ok(path) + Ok(NoteService::new(vault_context)) } -fn serialize_json(value: &T) -> napi::Result { - serde_json::to_string(value).map_err(|error| Error::from_reason(error.to_string())) +fn workspace_service(workspace_root: &str) -> napi::Result { + let (vault_context, index_repository) = build_vault_context(workspace_root)?; + + Ok(WorkspaceService::new(vault_context, index_repository)) } -fn serialize_note(note: Note) -> SerializableNote { - SerializableNote { - id: note.id.to_string(), - anchor: serialize_anchor(note.anchor), - content: note.content, - created_at: note.created_at, - updated_at: note.updated_at, +fn serialize_symbol_kind(kind: SymbolKind) -> String { + match kind { + SymbolKind::Function => "Function", + SymbolKind::Struct => "Struct", + SymbolKind::Enum => "Enum", + SymbolKind::Trait => "Trait", + SymbolKind::Impl => "Impl", + SymbolKind::Method => "Method", + SymbolKind::Unknown => "Unknown", } + .to_string() } fn serialize_anchor(anchor: NoteAnchor) -> SerializableAnchor { @@ -152,6 +157,16 @@ fn serialize_anchor(anchor: NoteAnchor) -> SerializableAnchor { } } +fn serialize_note(note: Note) -> SerializableNote { + SerializableNote { + id: note.id.to_string(), + anchor: serialize_anchor(note.anchor), + content: note.content, + created_at: note.created_at, + updated_at: note.updated_at, + } +} + fn serialize_note_view(view: NoteView) -> SerializableNoteView { SerializableNoteView { source_file: view.source_file.to_string_lossy().to_string(), @@ -159,19 +174,6 @@ fn serialize_note_view(view: NoteView) -> SerializableNoteView { } } -fn serialize_symbol_kind(kind: SymbolKind) -> String { - match kind { - SymbolKind::Function => "Function", - SymbolKind::Struct => "Struct", - SymbolKind::Enum => "Enum", - SymbolKind::Trait => "Trait", - SymbolKind::Impl => "Impl", - SymbolKind::Method => "Method", - SymbolKind::Unknown => "Unknown", - } - .to_string() -} - fn serialize_stats(stats: WorkspaceStats) -> SerializableWorkspaceStats { SerializableWorkspaceStats { file_count: stats.file_count, @@ -204,7 +206,7 @@ pub fn add_line_note( column: u32, content: String, ) -> napi::Result { - let service = note_service(&workspace_root)?; + let mut service = note_service(&workspace_root)?; let source_file = normalize_source_file(&workspace_root, &source_file)?; let note = service @@ -222,9 +224,8 @@ pub fn add_line_note( #[napi] pub fn list_notes(workspace_root: String, source_file: String) -> napi::Result { - let service = note_service(&workspace_root)?; + let mut service = note_service(&workspace_root)?; let source_file = normalize_source_file(&workspace_root, &source_file)?; - let views = service.list_notes(source_file).map_err(napi_error)?; let serialized: Vec<_> = views.into_iter().map(serialize_note_view).collect(); @@ -238,7 +239,7 @@ pub fn update_note( note_id: String, content: String, ) -> napi::Result { - let service = note_service(&workspace_root)?; + let mut service = note_service(&workspace_root)?; let source_file = normalize_source_file(&workspace_root, &source_file)?; let note_id = parse_uuid(¬e_id)?; @@ -262,7 +263,7 @@ pub fn delete_note( source_file: String, note_id: String, ) -> napi::Result<()> { - let service = note_service(&workspace_root)?; + let mut service = note_service(&workspace_root)?; let source_file = normalize_source_file(&workspace_root, &source_file)?; let note_id = parse_uuid(¬e_id)?; @@ -273,7 +274,7 @@ pub fn delete_note( #[napi] pub fn search_notes(workspace_root: String, keyword: String) -> napi::Result { - let service = note_service(&workspace_root)?; + let mut service = note_service(&workspace_root)?; let views = service.search_notes(&keyword).map_err(napi_error)?; let serialized: Vec<_> = views.into_iter().map(serialize_note_view).collect(); @@ -282,7 +283,7 @@ pub fn search_notes(workspace_root: String, keyword: String) -> napi::Result napi::Result { - let service = workspace_service(&workspace_root)?; + let mut service = workspace_service(&workspace_root)?; let stats = service.stats().map_err(napi_error)?; serialize_json(&serialize_stats(stats)) @@ -290,7 +291,7 @@ pub fn workspace_stats(workspace_root: String) -> napi::Result { #[napi] pub fn workspace_health(workspace_root: String) -> napi::Result { - let service = workspace_service(&workspace_root)?; + let mut service = workspace_service(&workspace_root)?; let health = service.health_check().map_err(napi_error)?; serialize_json(&serialize_health(health)) @@ -298,7 +299,7 @@ pub fn workspace_health(workspace_root: String) -> napi::Result { #[napi] pub fn repair_suggestions(workspace_root: String) -> napi::Result { - let service = workspace_service(&workspace_root)?; + let mut service = workspace_service(&workspace_root)?; let suggestions = service.repair_suggestions().map_err(napi_error)?; let serialized: Vec<_> = suggestions .into_iter() @@ -310,7 +311,7 @@ pub fn repair_suggestions(workspace_root: String) -> napi::Result { #[napi] pub fn apply_repairs(workspace_root: String) -> napi::Result { - let service = workspace_service(&workspace_root)?; + let mut service = workspace_service(&workspace_root)?; let repaired = service.apply_repairs().map_err(napi_error)?; Ok(repaired as u32) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5862d3e..5ef5426 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,126 +1,207 @@ -# FrilVault Architecture +# πŸ“˜ FrilVault Architecture -## Vision +## Version -FrilVault is a personal knowledge vault for developers. +v0.1 (Current Core + VaultContext + Cache Transition) -The system should let a developer attach private notes to code without changing the code itself. +--- -## Core Principles +# 1. Vision -### Local First +FrilVault is a developer-focused personal knowledge vault. -All data is stored locally. +It allows developers to attach structured, persistent notes to source code without modifying the code itself. -### Source Code Integrity +The system acts as a **knowledge layer on top of codebases**, not a code annotation tool. -FrilVault must not modify source files. +--- -### Shared Core Logic +# 2. Core Principles -Editor integrations should reuse the same core behavior instead of reimplementing note logic. +## 2.1 Local First -### Editor-Agnostic Design +All data is stored locally inside the `.vault` directory. -VS Code is one integration surface, not the product boundary. +No external service dependency exists. -## Current Repository Architecture +--- + +## 2.2 Source Code Integrity + +FrilVault must never modify source files. + +All metadata and notes are stored externally. + +--- + +## 2.3 Shared Core Logic + +All clients must reuse `frilvault-core` as the single source of truth. + +No business logic duplication in: + +- CLI +- VSCode extension +- Node bridge + +--- + +## 2.4 Runtime-Centric Design + +The system introduces a runtime container: + +> VaultContext + +This is responsible for: + +- caching +- repository coordination +- index access +- runtime optimization + +--- + +## 2.5 Editor-Agnostic Design + +VSCode is an integration layer, not the system boundary. + +Future editors must be able to integrate without modifying core logic. + +--- + +# 3. Repository Architecture ```text frilvault β”œβ”€β”€ crates β”‚ β”œβ”€β”€ frilvault-core β”‚ └── frilvault-node +β”‚ └── apps β”œβ”€β”€ frilvault-cli └── vscode-extension ``` -## `frilvault-core` +--- -`frilvault-core` owns domain behavior. +# 4. Core Architecture (frilvault-core) + +## 4.1 Module Structure ```text frilvault-core β”œβ”€β”€ note β”‚ β”œβ”€β”€ entity β”‚ β”œβ”€β”€ repository -β”‚ └── service +β”‚ β”œβ”€β”€ service +β”‚ └── view +β”‚ β”œβ”€β”€ workspace β”‚ β”œβ”€β”€ entity β”‚ β”œβ”€β”€ path β”‚ β”œβ”€β”€ repository β”‚ └── service +β”‚ β”œβ”€β”€ storage β”œβ”€β”€ parser -└── cache +β”œβ”€β”€ cache +└── vault_context ``` -Responsibilities: +--- + +## 4.2 Responsibilities + +### Note Domain + +Responsible for: + +- CRUD operations +- line-based anchors +- symbol-based anchors +- note search +- YAML persistence + +--- + +### Workspace Domain + +Responsible for: -- note CRUD -- search -- YAML serialization -- workspace metadata - workspace indexing -- workspace health checks -- repair suggestion and application +- statistics +- health checks +- repair suggestions +- repair execution + +--- + +### VaultContext (Runtime Core) -## `frilvault-cli` +VaultContext is the runtime container of FrilVault. -`frilvault-cli` is the command-line surface over `frilvault-core`. +It owns: + +- YamlNoteRepository +- WorkspaceIndexRepository +- NoteCache Responsibilities: -- parse user input -- call core services -- print text or JSON output +- cache-aware note loading +- cache invalidation +- unified access layer for services -Current notable interface: +--- -- `flvt list --format json` +### Cache Layer -## `frilvault-node` +In-memory optimization layer used only in long-running processes. -`frilvault-node` is a Node-API bridge around `frilvault-core`. +Current responsibilities: -Purpose: +- note caching +- future: index cache, symbol cache -- expose core operations to Node-based editor runtimes -- avoid duplicating domain behavior in TypeScript +Important: -Current exposed operations are still selective rather than complete. +CLI usage is short-lived; cache is primarily useful for VSCode and Node runtime. -## VS Code Extension +--- -The VS Code extension is the UI layer. +# 5. Service Layer -Current feature structure: +## 5.1 NoteService -```text -src/features -β”œβ”€β”€ add-note -β”œβ”€β”€ decorations -└── notes-panel -``` +Responsible for: + +- note CRUD orchestration +- search coordination +- interaction with VaultContext + +Important: + +- Must not directly access repositories +- Must go through VaultContext -Current behavior split: +--- -- CLI-backed flows: - - add note - - active-file notes panel - - gutter decorations -- Node-bridge-backed flows: - - edit note - - delete note - - search - - stats - - health - - repair +## 5.2 WorkspaceService -This split works for the MVP, but it is transitional. +Responsible for: -## Storage Model +- workspace statistics +- health checking +- repair system +- file scanning + +Important: + +- Uses both index data and note data via VaultContext + +--- + +# 6. Storage Model ```text .vault @@ -130,7 +211,11 @@ This split works for the MVP, but it is transitional. └── workspace.yml ``` -## Repair Flow +--- + +# 7. Repair System + +## Flow ```text health_check @@ -140,33 +225,147 @@ repair_suggestions apply_repairs ``` -Current repair implementation: +--- -- filename-based candidate matching +## Current Implementation -Planned improvements: +- filename-based matching +- heuristic candidate selection + +--- + +## Future Improvements -- interactive candidate selection -- stronger similarity heuristics - symbol-aware repair +- interactive selection +- semantic matching + +--- + +# 8. Runtime Data Flow + +## 8.1 Read Path + +```text +Client (CLI / VSCode) +↓ +Service +↓ +VaultContext +↓ +Cache (hit/miss) +↓ +Repository (fallback) +↓ +Filesystem (YAML) +``` + +--- + +## 8.2 Write Path + +```text +Client +↓ +Service +↓ +Repository +↓ +Filesystem +↓ +Cache invalidation +``` + +--- -## Target Direction +# 9. Editor Integration Model -Long-term direction: +## 9.1 VSCode Architecture (Current Transition State) ```text -Editor UI - β”‚ - β”œβ”€β”€ CLI integration - β”‚ - └── Native bridge integration - β”‚ - β–Ό - frilvault-core +VSCode Extension +β”œβ”€β”€ CLI-based operations +β”œβ”€β”€ Node bridge operations +└── Core-backed features +``` + +--- + +## 9.2 Target Architecture + +```text +VSCode Extension +↓ +Node Bridge +↓ +VaultContext +↓ +frilvault-core ``` -Desired properties: +--- + +## 9.3 Features Owned by VSCode Layer + +- gutter decorations +- hover previews +- sidebar panels +- commands UI + +No business logic allowed here. + +--- + +# 10. Key Design Constraints + +## 10.1 Single Source of Truth + +All logic must live in: + +> frilvault-core + +--- + +## 10.2 No Repository Leakage + +Services must not directly depend on repositories. + +All access goes through VaultContext. + +--- + +## 10.3 Cache Transparency + +Cache must be invisible to clients. + +Clients should not know whether data is cached or loaded from disk. + +--- + +# 11. Target Evolution + +## Current State + +- Core fully functional +- Cache introduced +- VaultContext introduced +- CLI stable +- Repair system functional + +--- + +## Next State + +- full VaultContext adoption +- unified service layer +- cache-driven reads +- VSCode integration stabilization + +--- + +## Future State -- one source of truth for note behavior -- reusable integration boundary for multiple editors -- minimal UI-specific logic outside the editor surface +- symbol resolution engine +- watcher system +- semantic search +- AI context layer diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 89fd16f..c5005e4 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -1,167 +1,372 @@ -# FrilVault Current State - -## Implemented - -### Core - -- Add note -- Update note -- Delete note -- List notes -- Search notes -- Line anchors -- Symbol anchors -- YAML note storage -- Workspace metadata -- Workspace index -- Workspace statistics -- Workspace health check -- Repair suggestions -- Automatic repair apply - -### CLI - -Available commands: - -```bash -flvt add -flvt list -flvt update -flvt delete -flvt search -flvt stats -flvt doctor -flvt repair +# πŸ“˜ FrilVault Current State + +## Version + +v0.1 β€” Core + Runtime Transition Phase + +--- + +# 1. System Status Overview + +FrilVault is currently in a **runtime transition stage**. + +The system has moved beyond a simple note storage tool and is evolving into a **runtime-based knowledge layer for codebases**. + +--- + +## Current Stage Classification + +```text id="xk9v2p" +βœ” Core domain system: COMPLETED +βœ” Workspace system: COMPLETED +βœ” Repair system: COMPLETED +βœ” CLI interface: COMPLETED +βœ” Node bridge: MVP COMPLETED + +β†’ Runtime architecture (VaultContext): IN TRANSITION +β†’ Cache system: PARTIALLY ACTIVE +β†’ VSCode integration: TRANSITIONAL (CLI + Node hybrid) +``` + +--- + +# 2. Core System Status (frilvault-core) + +## 2.1 Completed Modules + +### Note System + +- CRUD operations implemented +- YAML-based storage +- line anchors implemented +- symbol anchors implemented +- keyword + symbol search implemented + +--- + +### Workspace System + +- workspace indexing implemented +- workspace statistics implemented +- health check system implemented +- repair suggestion system implemented +- repair execution system implemented + +--- + +### Storage Layer + +- `.vault` directory structure implemented +- YAML persistence implemented +- note file structure stable + +--- + +### Parser Layer + +- YAML parser implemented +- note serialization/deserialization stable + +--- + +## 2.2 Runtime Layer (VaultContext) + +### Status + +```text id="qv8m1z" +VaultContext exists and is functional but not fully centralized +``` + +### Responsibilities (Current) + +- Repository aggregation +- Partial cache handling +- Note loading coordination + +### Missing Full Adoption + +- Service layer still partially accesses repositories directly +- Cache not fully integrated across all read paths +- Invalidation not uniformly enforced + +--- + +## 2.3 Cache System + +### Status + +```text id="m3xk9v" +Cache exists and works in isolated flows +``` + +### Implemented + +- NoteCache implemented +- basic cache get/insert +- manual invalidation hooks + +### Missing + +- full read-path integration (search/stats) +- workspace index caching +- watcher-driven invalidation + +--- + +# 3. Service Layer Status + +## 3.1 NoteService + +### Status + +- partially migrated to VaultContext +- still contains repository-level interactions in some paths + +### Current Behavior + +- note CRUD works +- search works (hybrid repository + vault_context usage) +- cache partially used + +--- + +## 3.2 WorkspaceService + +### Status + +- mixed architecture (index + repository + vault_context hybrid) + +### Current Behavior + +- stats fully functional +- health check functional +- repair system functional +- workspace file scanning implemented + +--- + +# 4. CLI Status (frilvault-cli) + +## Status: STABLE + +### Implemented Commands + +- add +- list +- update +- delete +- search +- stats +- health +- repair + +### Characteristics + +- thin wrapper over core services +- no business logic inside CLI +- text + JSON output support + +--- + +# 5. Node Bridge (frilvault-node) + +## Status: MVP COMPLETE + +### Role + +- exposes core functionality to Node-based environments + +### Current State + +- partial API coverage +- used by VSCode extension for non-CLI operations +- still evolving toward full replacement of CLI dependency in editor runtime + +--- + +# 6. VSCode Extension Status + +## Status: TRANSITIONAL ARCHITECTURE + +### Current Integration Model + +```text id="v0m2qz" +Hybrid system: +- CLI-based calls (simple operations) +- Node bridge calls (advanced operations) +``` + +--- + +### Implemented Features + +- gutter decorations (partial) +- add note command +- active file notes panel +- basic note visualization + +--- + +### Partial Features + +- hover previews (incomplete / evolving) +- search integration (mixed backend) +- repair integration (node bridge dependent) + +--- + +### Architectural Issue + +```text id="b9xk4z" +Multiple execution paths: +CLI path + Node bridge path +``` + +This is temporary and will be unified later. + +--- + +# 7. Storage Model Status + +## Current Structure + +```text id="n3q8lm" +.vault +β”œβ”€β”€ notes/ +β”œβ”€β”€ cache/ +β”œβ”€β”€ index/ +└── workspace.yml ``` -Notable current behavior: +--- + +## Status -- `flvt list` supports `--format json` -- repair works with filename-based matching +- structure implemented +- index and cache directories partially used +- schema stable -### Node Bridge +--- -`frilvault-node` is implemented as a Node-API wrapper around `frilvault-core`. +# 8. Repair System Status -Currently exposed operations: +## Status: FUNCTIONAL -- add line note -- list notes -- update note -- delete note -- search notes -- workspace stats -- workspace health -- repair suggestions -- apply repairs +### Pipeline -### VS Code Extension +```text id="r8m2kv" +health_check +β†’ repair_suggestions +β†’ apply_repairs +``` -Status: +--- -MVP implemented. +### Current Behavior -Current scope: +- filename-based matching +- heuristic candidate detection +- basic file move/rename support -- `FrilVault: Add Note` -- `FrilVault Notes` side panel -- gutter decorations for line notes -- note open from TreeView -- note edit flow -- note delete flow -- search notes -- workspace stats -- workspace health -- repair apply +--- -Current implementation split: +### Limitations -- CLI-backed: - - add note - - notes side panel - - gutter decorations -- Node-bridge-backed: - - edit note - - delete note - - search - - stats - - health - - repair +- no symbol-aware repair +- no interactive confirmation flow +- no similarity scoring engine -### Tests +--- -Implemented test coverage: +# 9. Architecture Stability Status -- `frilvault-core` unit tests -- `frilvault-node` compile-level test pass -- VS Code integration tests for: - - add note command - - notes panel - - CLI JSON parsing path +## Stable Components -## Refactoring Completed +- Note domain +- Workspace domain +- CLI +- Storage layer +- Parser layer -### Core Domain Split +--- -Separated: +## Evolving Components -- note entities -- note repository -- note service -- workspace entities -- workspace repositories -- workspace service +- VaultContext (central runtime) +- Cache system +- Service layer (migration in progress) -### VS Code Feature Split +--- -Separated: +## Unstable / Transitional -- `features/add-note` -- `features/notes-panel` -- `features/decorations` +- VSCode integration paths +- Node bridge full coverage +- cache invalidation consistency +- repository access elimination -## In Progress +--- -### Editor Integration Cleanup +# 10. System Flow (Current Reality) -Goal: +## Read Flow -- reduce mixed CLI and Node bridge usage -- move toward a more consistent integration boundary +```text id="c8m1zp" +Service +β†’ VaultContext (partial) +β†’ Cache (partial) +β†’ Repository (fallback) +β†’ filesystem +``` -## Not Yet Implemented +--- -### Watcher +## Write Flow -Status: +```text id="f7k2lm" +Service +β†’ Repository +β†’ filesystem +β†’ (manual cache invalidation) +``` -Not started. +--- -### Rich Symbol Workflows +# 11. Target State (Reference Only) -Status: +## Ideal Future Architecture -Not started. +```text id="p9m4xz" +VSCode / CLI / Node + ↓ + Service Layer + ↓ + VaultContext (FULL CONTROL) + ↓ + Cache + Index + Repository +``` -Current limitation: +--- -- symbol anchors exist in the core model -- the VS Code UI is still primarily line-note oriented +# 12. Key Insight -### Semantic Search +FrilVault is currently in a **structural consolidation phase**: -Status: +- core is stable +- runtime layer is emerging +- integration paths are being unified +- cache system is being activated across system boundaries -Not started. +--- -### AI Context Engine +# 13. Summary -Status: +FrilVault is no longer a note tool. -Not started. +It is currently transitioning into: -### IntelliJ Integration +> a runtime knowledge system for codebases -Status: +The critical missing step is: -Not started. +> full VaultContext centralization + cache-driven read path unification diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4ad20d1..6477c28 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,79 +1,300 @@ -# FrilVault Roadmap +# πŸ“˜ FrilVault Roadmap -## Phase 1 +## Version -Completed: +v0.1 β†’ v1.0 (Core β†’ Runtime β†’ IDE Transition) -- YAML note storage -- note CRUD -- search -- line anchors -- symbol anchors -- workspace index -- workspace stats -- workspace doctor +--- + +# Phase 1 β€” Core Foundation (COMPLETED) + +## Goal + +Local note system with structured storage and basic workspace intelligence. + +## Completed Features + +### Note System + +- YAML-based note storage +- Note CRUD (create / update / delete / list) +- Line-based anchors +- Symbol-based anchors +- Note search (content + symbol) + +--- + +### Workspace System + +- Workspace indexing +- Workspace statistics +- Workspace health check +- Repair suggestion system +- Repair execution (file rename/move support) + +--- + +### Storage Layer + +- `.vault` directory structure +- YAML persistence model +- structured note file format + +--- + +### CLI Layer (frilvault-cli) + +- add note +- list notes +- update note +- delete note +- search notes +- stats +- health check - repair -## Phase 2 +--- + +# Phase 2 β€” Runtime Foundation (IN PROGRESS) + +## Goal + +Introduce long-running runtime abstraction and remove stateless execution limitations. + +--- + +## Completed / In Progress + +### VaultContext (Runtime Container) + +- Central runtime state object introduced +- Owns repositories +- Owns cache +- Provides unified access layer + +--- + +### Cache System (Partial) + +- NoteCache implemented +- Cache-aware note loading introduced +- Cache invalidation hooks added +- Repository bypass eliminated in read path + +--- + +## Remaining Tasks + +- full service migration to VaultContext-only access +- unify read/write paths through VaultContext +- ensure cache consistency across all operations + +--- + +# Phase 3 β€” Service Unification (CURRENT FOCUS) + +## Goal + +Remove repository leakage from service layer. + +--- + +## Tasks + +### Service Layer Refactor + +- NoteService β†’ VaultContext-only access +- WorkspaceService β†’ VaultContext-only access +- Remove direct repository usage from services + +--- + +### Cache Completion + +- ensure cache used in: + - list notes + - search notes + - symbol queries (future) + +- ensure invalidation on: + - add note + - update note + - delete note + - repair operations + +--- + +### API Stabilization + +- finalize VaultContext API surface +- define stable internal service contracts + +--- + +# Phase 4 β€” Watcher System (NEXT CORE MILESTONE) + +## Goal + +Enable automatic system updates in long-running environments (VSCode, Node runtime). -Completed or mostly completed: +--- + +## Tasks + +### File System Watcher + +- watch `.vault` changes +- watch source file changes + +--- + +### Reactive Cache System + +- automatic cache invalidation +- automatic index rebuild +- incremental updates (future optimization) + +--- + +### Runtime Synchronization + +- ensure VSCode stays in sync with core state + +--- + +# Phase 5 β€” Symbol Intelligence Layer + +## Goal + +Move from β€œnote system” β†’ β€œcode-aware knowledge system” + +--- + +## Tasks + +### Symbol Query System + +- search notes by symbol name +- partial symbol matching +- namespace-style queries + +--- + +### Symbol Resolution Engine (critical) + +- track function rename +- track symbol movement across files +- maintain note attachment consistency + +--- + +### Symbol Index + +- build structured symbol graph +- connect notes ↔ symbols ↔ files + +--- + +# Phase 6 β€” VSCode Full Integration + +## Goal + +Replace CLI dependency with native editor runtime integration. + +--- + +## Tasks + +### UI Features -- Node bridge MVP -- VS Code extension MVP -- active-file notes panel -- add note command - gutter decorations -- VS Code integration tests +- hover previews +- sidebar notes panel +- symbol tree view + +--- + +### Integration Layer + +- Node bridge full adoption +- remove CLI dependency in runtime path +- unify extension β†’ VaultContext flow + +--- + +# Phase 7 β€” Knowledge Layer Expansion -## Phase 3 +## Goal -Current focus: +Transform FrilVault into a structured developer memory system. -- reduce mixed integration paths in the VS Code extension -- expand editor workflows around symbols -- improve extension UX consistency +--- -Tasks: +## Tasks -- align CLI and Node bridge usage -- improve note edit and delete UX -- add richer note previews -- strengthen extension test coverage +### Semantic Search -## Phase 4 +- vector-based search (future) +- context-aware retrieval -Workspace runtime improvements: +--- -- watcher support -- cache invalidation -- long-running editor session optimization +### AI Context Engine -Tasks: +- build context from: + - notes + - workspace index + - symbol graph -- monitor `.vault` changes -- monitor source file changes -- invalidate cached note data +--- -## Phase 5 +### Project Memory Graph -Symbol and search improvements: +- file ↔ symbol ↔ note relationships +- dependency-aware context building -- symbol resolution -- rename tracking -- indexed search improvements -- symbol-aware repair +--- -## Phase 6 +# Phase 8 β€” Multi-Editor Support -Additional editor integrations: +## Goal + +Make FrilVault editor-agnostic. + +--- + +## Tasks - IntelliJ plugin -- shared editor integration strategy around `frilvault-core` +- Neovim integration (optional future) +- shared core runtime usage + +--- + +# πŸš€ Current Position Summary + +```text +FrilVault is currently transitioning from: + +Phase 2 β†’ Phase 3 + +Meaning: +Core is stable +Runtime layer is being introduced +Cache is becoming central +Services are being unified +``` + +--- + +# 🎯 Key Strategic Direction + +FrilVault is evolving into: -## Phase 7 +> A runtime knowledge layer for codebases, not a note tool -Knowledge-layer expansion: +Core abstraction enabling this: -- AI context engine -- semantic search -- project-level context assembly +```text +VaultContext + Cache + Workspace Index + Symbol Engine +```