diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af0b435..189c2658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,13 @@ All notable documentation and user-facing behavior changes are tracked in this f ## Unreleased -- Added **Attached Code Repositories & AST Knowledge Graph Integration**: +- Added **Diagrams & Media Asset Manager & Media Viewer Standardization**: + - High-density table layout with fixed 260px sidebar replacing card grid for managing workspace media, diagrams, PDFs, and recordings. + - Category filters with real-time counters and high-contrast badges for Diagrams, UI Prototypes, Images, PDFs, Videos, Audio, Transcripts, and Documents. + - Safe asset deletion workflow with reference checking to prevent accidental broken links in notes. + - Co-located asset bundle folders (`media/audio//` and `media/video//`) containing primary recording files alongside companion `transcript.json`. + - Unified full-screen `MediaPreviewPane` modal with de-duplicated header bar, 100% full-width stretch, and interactive Speech-to-Text transcript drawer. + - Interactive transcript card rendering (`.markdown-transcript-card`) in markdown note preview with View, Copy, and Download actions. - Connect external Git repositories to workspace metadata (`.notes-app/metadata.json`) with branch and commit tracking. - Multi-language code AST extractor (`CodeKnowledgeSource.js`) parsing JS/TS, Python, Go, Rust, Java, C/C++, Ruby, and PHP files into structured code entities (CodeModule, CodeClass, CodeInterface, CodeFunction, APIEndpoint, DBModel) and mapping `IMPORTS`/`CONTAINS`/`DOCUMENTS` graph relationships. - Interactive **Attached Repositories Explorer** page (`AttachedReposPage.jsx`, `AttachedReposPage.css`) accessible via dashboard card, TitleBar menu (`Workspace → Attached Repositories`), and Command Palette (`open-attached-repos`). diff --git a/ai/diagnostics/AIHealth.js b/ai/diagnostics/AIHealth.js index db636e7b..8c4ce96c 100644 --- a/ai/diagnostics/AIHealth.js +++ b/ai/diagnostics/AIHealth.js @@ -8,6 +8,7 @@ const { aiService } = require('../core/AIService'); const path = require('path'); const fs = require('fs'); +const os = require('os'); function getEmbeddingStats(db) { let chunks = 0; @@ -153,10 +154,22 @@ function getSubsystemHealth() { console.error('[MCP Health] Failed to gather MCP stats:', err.message); } + const freeMemBytes = typeof os.freemem === 'function' ? os.freemem() : 0; + const totalMemBytes = typeof os.totalmem === 'function' ? os.totalmem() : 0; + const freeMemMb = Math.round(freeMemBytes / 1024 / 1024); + const totalMemMb = Math.round(totalMemBytes / 1024 / 1024); + const isLowMemory = freeMemMb < 400; + return { enabled: isEnabled, initialized: isInitialized, serverStatus: isEnabled ? 'Running' : 'Stopped', + systemMemory: { + freeMemMb, + totalMemMb, + isLowMemory, + mode: isLowMemory ? 'standby' : 'normal' + }, mcp: mcpStats, database: { status: dbStatus, diff --git a/ai/graph/EntityResolver.js b/ai/graph/EntityResolver.js index e2245b71..e8f81ab7 100644 --- a/ai/graph/EntityResolver.js +++ b/ai/graph/EntityResolver.js @@ -7,6 +7,48 @@ const { createLogger } = require('../core/logger'); const log = createLogger('EntityResolver'); +const CANONICAL_TYPE_MAP = { + person: 'Person', + organization: 'Organization', + company: 'Company', + location: 'Location', + event: 'Event', + concept: 'Concept', + project: 'Project', + task: 'Task', + decision: 'Decision', + idea: 'Idea', + tag: 'Tag', + image: 'Image', + document: 'Document', + folder: 'Folder', + workspace: 'Workspace', + note: 'Note', + section: 'Section', + externalurl: 'ExternalURL', + external_url: 'ExternalURL', + codeblock: 'CodeBlock', + diagram: 'Diagram', + keyterm: 'KeyTerm', + formula: 'Formula', + callout: 'Callout', + repo: 'Repo', + codemodule: 'CodeModule', + codeclass: 'CodeClass', + codeinterface: 'CodeInterface', + codefunction: 'CodeFunction', + apiendpoint: 'APIEndpoint', + dbmodel: 'DBModel' +}; + +function canonicalizeType(t) { + if (!t) return 'Concept'; + const clean = String(t).trim(); + const key = clean.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (CANONICAL_TYPE_MAP[key]) return CANONICAL_TYPE_MAP[key]; + return clean.charAt(0).toUpperCase() + clean.slice(1); +} + class EntityResolver { constructor(graphDb, embeddingService = null) { this.graphDb = graphDb; @@ -185,7 +227,7 @@ class EntityResolver { } } - return proposedType || 'Concept'; + return canonicalizeType(proposedType); } /** diff --git a/ai/graph/GraphBuilder.js b/ai/graph/GraphBuilder.js index 42377025..5f0997a2 100644 --- a/ai/graph/GraphBuilder.js +++ b/ai/graph/GraphBuilder.js @@ -65,7 +65,7 @@ class GraphBuilder { const ExcalidrawKnowledgeSource = require('./sources/ExcalidrawKnowledgeSource'); const DrawioKnowledgeSource = require('./sources/DrawioKnowledgeSource'); const MermaidKnowledgeSource = require('./sources/MermaidKnowledgeSource'); - const CodeKnowledgeSource = require('./sources/CodeKnowledgeSource'); + const { CodeKnowledgeSource } = require('./sources/code'); const registry = new KnowledgeSourceRegistry(); @@ -119,6 +119,29 @@ class GraphBuilder { : `ent-${item.source.sourceType()}-${String(ent.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; this.graphDb.upsertEntity({ id, name: ent.name, canonical_name: ent.name, type: ent.type || 'Entity', properties: ent.properties || {} }); } + + const evidenceMap = new Map(); + if (Array.isArray(evidence)) { + for (const ev of evidence) { + const evId = evidenceStore.addEvidence({ + sourceId: ev.sourceId || item.path, + extractor: ev.extractor || item.source.sourceType(), + subjectText: ev.subjectText || ev.subject_text || item.path, + subjectSpanStart: ev.subjectSpanStart ?? null, + subjectSpanEnd: ev.subjectSpanEnd ?? null, + predicateText: ev.predicateText || ev.predicate_text || 'related_to', + objectText: ev.objectText || ev.object_text || '', + objectSpanStart: ev.objectSpanStart ?? null, + objectSpanEnd: ev.objectSpanEnd ?? null, + rawSentence: ev.rawSentence || ev.raw_sentence || ev.subjectText || item.path, + confidence: ev.confidence || item.source.baseConfidence() || 1.0 + }); + if (evId && ev.subjectText && ev.predicateText && ev.objectText) { + evidenceMap.set(`${ev.subjectText}::${ev.predicateText}::${ev.objectText}`, evId); + } + } + } + for (const rel of relationships) { const srcId = this.graphService?.entityResolver ? this.graphService.entityResolver.generateEntityId(rel.source_name, rel.source_type || 'Entity') @@ -129,19 +152,15 @@ class GraphBuilder { if (srcId !== tgtId) { this.graphDb.upsertEntity({ id: srcId, name: rel.source_name, canonical_name: rel.source_name, type: rel.source_type || 'Entity' }); this.graphDb.upsertEntity({ id: tgtId, name: rel.target_name, canonical_name: rel.target_name, type: rel.target_type || 'Entity' }); - this.graphDb.upsertRelationship({ source_id: srcId, target_id: tgtId, type: rel.type, weight: rel.weight, confidence: rel.confidence }); - } - } - if (Array.isArray(evidence)) { - for (const ev of evidence) { - evidenceStore.addEvidence({ - sourceId: item.path, + const evId = evidenceMap.get(`${rel.source_name}::${rel.type}::${rel.target_name}`) || null; + this.graphDb.upsertRelationship({ + source_id: srcId, + target_id: tgtId, + type: rel.type, + weight: rel.weight, + confidence: rel.confidence, extractor: item.source.sourceType(), - subjectText: ev.subjectText || ev.subject_text || item.path, - predicateText: ev.predicateText || ev.predicate_text || 'related_to', - objectText: ev.objectText || ev.object_text || '', - rawSentence: ev.rawSentence || ev.raw_sentence || ev.subjectText || item.path, - confidence: ev.confidence || item.source.baseConfidence() || 1.0 + evidence_id: evId }); } } @@ -199,11 +218,53 @@ class GraphBuilder { const validator = new GraphValidationEngine(this.graphDb, this._logDb); await validator.validate(); + // Pre-compute & cache 2D force layout coordinates in SQLite + if (this.graphDb && typeof this.graphDb.recomputeLayout === 'function') { + try { + this.graphDb.recomputeLayout(); + } catch (lErr) { + log.warn('GraphLayoutEngine pass skipped:', lErr.message); + } + } + // Optimize SQLite query planner if (this.graphDb?.db) { try { this.graphDb.db.exec('PRAGMA ANALYZE;'); } catch { /* ignore */ } } + // Update attached repos scan stats in metadata.json + if (attachedRepos.length > 0 && workspaceRoot) { + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const metaObj = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(metaObj.attachedRepos)) { + let changed = false; + for (const r of metaObj.attachedRepos) { + if (!r || !r.id) continue; + let symbolCount = 0; + try { + const countRow = this.graphDb.db.prepare( + "SELECT COUNT(*) as count FROM entities WHERE properties LIKE ?" + ).get(`%"repoId":"${r.id}"%`); + symbolCount = countRow?.count || 0; + } catch { /* ignore count query */ } + + r.lastScannedAt = new Date().toISOString(); + r.status = 'indexed'; + r.symbolCount = symbolCount; + changed = true; + } + if (changed) { + fs.writeFileSync(metaPath, JSON.stringify(metaObj, null, 2), 'utf8'); + } + } + } + } catch (metaErr) { + log.warn('Failed to update attached repo scan stats in metadata.json:', metaErr.message); + } + } + log.info(`Knowledge Graph rebuild complete. Processed: ${processedCount}, Failed: ${failedCount}`); this._logDb?.addLog('graph', `Knowledge Graph rebuild complete. Processed: ${processedCount}, Failed: ${failedCount}`, 'info', { processedCount, diff --git a/ai/graph/GraphDB.js b/ai/graph/GraphDB.js index 3e8d1eb2..d9c4a249 100644 --- a/ai/graph/GraphDB.js +++ b/ai/graph/GraphDB.js @@ -85,7 +85,7 @@ class GraphDB { } // Versioned database schema migrations (Gap 3) - const TARGET_SCHEMA_VERSION = 4; + const TARGET_SCHEMA_VERSION = 5; let currentVersion = 0; try { const vRow = this.db.prepare('PRAGMA user_version').get(); @@ -158,6 +158,12 @@ class GraphDB { this.db.exec(CREATE_ENTITY_EMBEDDINGS_TABLE); } catch { /* ignore */ } } + if (fromVersion < 5) { + // Version 5: Add layout coordinates and node degree cache + try { this.db.exec('ALTER TABLE entities ADD COLUMN pos_x REAL;'); } catch { /* ignore */ } + try { this.db.exec('ALTER TABLE entities ADD COLUMN pos_y REAL;'); } catch { /* ignore */ } + try { this.db.exec('ALTER TABLE entities ADD COLUMN degree INTEGER DEFAULT 0;'); } catch { /* ignore */ } + } } upsertEntityVector(entityId, vectorArray) { @@ -376,8 +382,13 @@ class GraphDB { } catch { /* ignore junction insert error */ } } } catch (err) { - if (err.message?.includes('FOREIGN KEY') && evidence_id) { - stmt.run(source_id, target_id, type, weight, clampedConfidence, extractor, metadataJson, null); + if (err.message?.includes('FOREIGN KEY')) { + try { + const now = new Date().toISOString(); + this.db.prepare('INSERT OR IGNORE INTO entities (id, name, canonical_name, type, updated_at) VALUES (?, ?, ?, ?, ?)').run(source_id, source_id, source_id, 'Entity', now); + this.db.prepare('INSERT OR IGNORE INTO entities (id, name, canonical_name, type, updated_at) VALUES (?, ?, ?, ?, ?)').run(target_id, target_id, target_id, 'Entity', now); + stmt.run(source_id, target_id, type, weight, clampedConfidence, extractor, metadataJson, null); + } catch { /* ignore fallback error */ } } else { throw err; } @@ -443,7 +454,14 @@ class GraphDB { .map(e => { const props = typeof e.properties === 'string' ? JSON.parse(e.properties || '{}') : (e.properties || {}); const conf = typeof e.confidence === 'number' ? e.confidence : (typeof props.confidence === 'number' ? props.confidence : 1.0); - return { ...e, confidence: conf, properties: props }; + return { + ...e, + confidence: conf, + properties: props, + pos_x: typeof e.pos_x === 'number' && !isNaN(e.pos_x) ? e.pos_x : null, + pos_y: typeof e.pos_y === 'number' && !isNaN(e.pos_y) ? e.pos_y : null, + degree: typeof e.degree === 'number' ? e.degree : 0 + }; }) .filter(e => e.confidence >= minConfidence); @@ -467,6 +485,61 @@ class GraphDB { return { entities, relationships }; } + /** + * Batch update entity coordinates and connectivity degrees + * @param {Map|Array<{id: string, pos_x: number, pos_y: number, degree: number}>} positions + */ + updateNodePositions(positions) { + if (!this.db || !positions) return 0; + try { + const updateStmt = this.db.prepare('UPDATE entities SET pos_x = ?, pos_y = ?, degree = ? WHERE id = ?'); + let count = 0; + + if (positions instanceof Map) { + for (const [id, pos] of positions.entries()) { + try { + updateStmt.run(pos.x, pos.y, pos.degree || 0, id); + count++; + } catch { /* continue on single node error */ } + } + } else if (Array.isArray(positions)) { + for (const pos of positions) { + try { + updateStmt.run(pos.pos_x ?? pos.x, pos.pos_y ?? pos.y, pos.degree || 0, pos.id); + count++; + } catch { /* continue */ } + } + } + + return count; + } catch (err) { + log.warn('Failed to update node positions in SQLite:', err.message); + return 0; + } + } + + /** + * Recompute full graph layout and cache coordinates to SQLite + * @param {Object} options + */ + recomputeLayout(options = {}) { + if (!this.db) return null; + try { + const GraphLayoutEngine = require('./layout/GraphLayoutEngine'); + const { entities, relationships } = this.getAll(0.0); + if (!entities.length) return { nodes: [], positions: new Map() }; + + const layoutResult = GraphLayoutEngine.computeLayout(entities, relationships, options); + if (layoutResult?.positions) { + this.updateNodePositions(layoutResult.positions); + } + return layoutResult; + } catch (err) { + log.warn('Failed to recompute and cache graph layout:', err.message); + return null; + } + } + /** * Find entity by note path */ diff --git a/ai/graph/GraphSchema.js b/ai/graph/GraphSchema.js index 9a0ce2a6..1386d766 100644 --- a/ai/graph/GraphSchema.js +++ b/ai/graph/GraphSchema.js @@ -14,6 +14,9 @@ CREATE TABLE IF NOT EXISTS entities ( extractor TEXT DEFAULT 'gliner', model_version TEXT DEFAULT '2.1', confidence REAL DEFAULT 1.0, + pos_x REAL, + pos_y REAL, + degree INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); @@ -101,7 +104,10 @@ const ALTER_ENTITIES_ADD_COLUMNS = [ `ALTER TABLE entities ADD COLUMN source_count INTEGER DEFAULT 1;`, `ALTER TABLE entities ADD COLUMN first_seen_at TEXT DEFAULT (datetime('now'));`, `ALTER TABLE entities ADD COLUMN is_retired INTEGER DEFAULT 0;`, - `ALTER TABLE entities ADD COLUMN merged_into TEXT;` + `ALTER TABLE entities ADD COLUMN merged_into TEXT;`, + `ALTER TABLE entities ADD COLUMN pos_x REAL;`, + `ALTER TABLE entities ADD COLUMN pos_y REAL;`, + `ALTER TABLE entities ADD COLUMN degree INTEGER DEFAULT 0;` ]; const CREATE_RELATIONSHIP_EVIDENCE_TABLE = ` diff --git a/ai/graph/layout/GraphLayoutEngine.js b/ai/graph/layout/GraphLayoutEngine.js new file mode 100644 index 00000000..8a3f1f53 --- /dev/null +++ b/ai/graph/layout/GraphLayoutEngine.js @@ -0,0 +1,113 @@ +/** + * GraphLayoutEngine + * Pure mathematical / physical force simulation layout calculator. + * Independent of SQLite / IPC layers for clean modularity and testing. + */ + +const d3Force = require('d3-force'); + +class GraphLayoutEngine { + /** + * Compute 2D coordinates and connectivity degree for graph nodes + * @param {Array} entities - List of entity objects with id + * @param {Array} relationships - List of relationship objects with source_id and target_id + * @param {Object} options - Force layout configuration + * @returns {{ nodes: Array, positions: Map }} + */ + static computeLayout(entities = [], relationships = [], options = {}) { + if (!Array.isArray(entities) || entities.length === 0) { + return { nodes: [], positions: new Map() }; + } + + const { + width = 1000, + height = 800, + ticks = Math.min(100, Math.max(30, Math.round(entities.length * 0.8))), + baseDistance = 140, + baseCharge = -280, + baseCollision = 60 + } = options; + + // 1. Calculate connectivity degree per entity + const degrees = new Map(); + for (const ent of entities) { + degrees.set(ent.id, 0); + } + for (const rel of relationships) { + if (degrees.has(rel.source_id)) { + degrees.set(rel.source_id, degrees.get(rel.source_id) + 1); + } + if (degrees.has(rel.target_id)) { + degrees.set(rel.target_id, degrees.get(rel.target_id) + 1); + } + } + + // 2. Prepare nodes for force simulation + const entityIds = new Set(entities.map(e => e.id)); + const forceNodes = entities.map((entity, idx) => { + // Preserve existing valid positions if available, otherwise distribute around center + const hasValidX = typeof entity.pos_x === 'number' && !isNaN(entity.pos_x); + const hasValidY = typeof entity.pos_y === 'number' && !isNaN(entity.pos_y); + + return { + id: entity.id, + entity, + degree: degrees.get(entity.id) || 0, + x: hasValidX ? entity.pos_x : (width / 2) + (Math.random() - 0.5) * (width * 0.7), + y: hasValidY ? entity.pos_y : (height / 2) + (Math.random() - 0.5) * (height * 0.7), + index: idx + }; + }); + + // 3. Prepare filtered links + const forceLinks = relationships + .filter(rel => entityIds.has(rel.source_id) && entityIds.has(rel.target_id)) + .map(rel => ({ + source: rel.source_id, + target: rel.target_id, + weight: rel.weight || 1.0 + })); + + // 4. Configure dynamic forces based on graph scale + const nodeCount = forceNodes.length; + const dynamicDistance = Math.max(80, Math.min(300, baseDistance + (nodeCount > 100 ? 50 : 0))); + const dynamicCharge = Math.min(-150, baseCharge - (nodeCount > 100 ? 150 : 0)); + const dynamicCollision = Math.max(40, baseCollision + (nodeCount > 100 ? 20 : 0)); + + const simulation = d3Force.forceSimulation(forceNodes) + .force('link', d3Force.forceLink(forceLinks).id(d => d.id).distance(dynamicDistance)) + .force('charge', d3Force.forceManyBody().strength(dynamicCharge)) + .force('center', d3Force.forceCenter(width / 2, height / 2)) + .force('collision', d3Force.forceCollide().radius(dynamicCollision)) + .stop(); + + // 5. Run simulation synchronously in worker + for (let i = 0; i < ticks; i++) { + simulation.tick(); + } + + // 6. Build positions map and normalized node results + const positions = new Map(); + const resultNodes = forceNodes.map(node => { + const x = isNaN(node.x) || typeof node.x !== 'number' ? width / 2 : Math.round(node.x * 10) / 10; + const y = isNaN(node.y) || typeof node.y !== 'number' ? height / 2 : Math.round(node.y * 10) / 10; + const deg = node.degree; + + positions.set(node.id, { x, y, degree: deg }); + return { + id: node.id, + pos_x: x, + pos_y: y, + degree: deg, + entity: node.entity + }; + }); + + return { + nodes: resultNodes, + positions + }; + } +} + +module.exports = GraphLayoutEngine; diff --git a/ai/graph/semantic/adapters/GLiNER2RelexAdapter.js b/ai/graph/semantic/adapters/GLiNER2RelexAdapter.js index 017f7463..2d4c5b6b 100644 --- a/ai/graph/semantic/adapters/GLiNER2RelexAdapter.js +++ b/ai/graph/semantic/adapters/GLiNER2RelexAdapter.js @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); const ModelAdapter = require('../ModelAdapter'); const { Entity, Relationship, Evidence, ExtractionResult } = require('../schemas/ExtractionResult'); const { createLogger } = require('../../../core/logger'); @@ -192,6 +193,11 @@ class GLiNER2RelexAdapter extends ModelAdapter { if (!fs.existsSync(filePath) || fs.statSync(filePath).size < 100) return null; try { + if (typeof os.freemem === "function" && os.freemem() < 400 * 1024 * 1024) { + log.info(`System free memory is low (${Math.round(os.freemem() / 1024 / 1024)}MB); deferring heavy ONNX session load for ${fileName}.`); + return null; + } + if (this.isWebRuntime) { const fileBuf = fs.readFileSync(filePath); const uint8 = new Uint8Array(fileBuf.buffer, fileBuf.byteOffset, fileBuf.byteLength); @@ -211,7 +217,11 @@ class GLiNER2RelexAdapter extends ModelAdapter { return await this.ort.InferenceSession.create(filePath, opts); } } catch (err) { - log.warn(`Failed to create ONNX session for ${fileName}:`, err.message); + if (err.message && (err.message.includes("bad_alloc") || err.message.includes("ERROR_CODE: 6"))) { + log.info(`ONNX allocation unavailable for ${fileName} due to memory constraints; using standby extraction.`); + } else { + log.warn(`Failed to create ONNX session for ${fileName}:`, err.message); + } return null; } } diff --git a/ai/graph/sources/CodeKnowledgeSource.js b/ai/graph/sources/CodeKnowledgeSource.js deleted file mode 100644 index 0df003d4..00000000 --- a/ai/graph/sources/CodeKnowledgeSource.js +++ /dev/null @@ -1,478 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const KnowledgeSource = require('./KnowledgeSource'); - -// Supported extensions -const CODE_EXTENSIONS = new Set([ - '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', - '.py', - '.go', - '.rs', - '.java', - '.c', '.cpp', '.h', '.hpp', '.cs', - '.rb', '.php', - '.vue', '.svelte' -]); - -const IGNORED_DIRECTORIES = new Set([ - 'node_modules', 'dist', 'build', '.git', '.svn', '.hg', - 'vendor', 'coverage', '.next', '.nuxt', '.output', - '__pycache__', '.pytest_cache', '.venv', 'venv', 'env', - 'bin', 'obj', 'target', '.idea', '.vscode' -]); - -class CodeKnowledgeSource extends KnowledgeSource { - constructor(attachedRepos = []) { - super(); - this.attachedRepos = Array.isArray(attachedRepos) ? attachedRepos : []; - } - - sourceType() { - return 'code'; - } - - baseConfidence() { - return 0.95; - } - - supports(filePath) { - if (!filePath || typeof filePath !== 'string') return false; - const ext = path.extname(filePath).toLowerCase(); - return CODE_EXTENSIONS.has(ext); - } - - /** - * Discovers code files from all attached repositories. - */ - discover(workspaceRoot) { - const discovered = []; - - // Also check if workspaceRoot itself has code or if attachedRepos are configured - let repos = [...this.attachedRepos]; - - // Attempt to load attached repos from workspace metadata if not pre-provided - if (repos.length === 0 && workspaceRoot) { - try { - const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); - if (fs.existsSync(metaPath)) { - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); - if (Array.isArray(meta.attachedRepos)) { - repos = meta.attachedRepos; - } - } - } catch { /* ignore */ } - } - - for (const repo of repos) { - if (!repo || !repo.path || !fs.existsSync(repo.path)) continue; - try { - this._walkRepo(repo.path, repo, discovered, 0, 1500); - } catch (err) { - console.warn(`[CodeKnowledgeSource] Failed walking repo ${repo.name || repo.path}:`, err.message); - } - } - - return discovered; - } - - _walkRepo(dir, repo, result, depth = 0, maxFiles = 1500) { - if (depth > 12 || result.length >= maxFiles) return; - let entries = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (result.length >= maxFiles) break; - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - if (!IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.')) { - this._walkRepo(fullPath, repo, result, depth + 1, maxFiles); - } - } else if (entry.isFile()) { - const ext = path.extname(entry.name).toLowerCase(); - if (CODE_EXTENSIONS.has(ext)) { - // Check file size (skip minified / huge files > 512KB) - try { - const stat = fs.statSync(fullPath); - if (stat.size < 512 * 1024) { - result.push(fullPath); - } - } catch { /* ignore */ } - } - } - } - } - - /** - * Find matching repo for a file path - */ - _getRepoForFile(filePath) { - const normalized = path.resolve(filePath); - for (const repo of this.attachedRepos) { - if (repo && repo.path && normalized.startsWith(path.resolve(repo.path))) { - return repo; - } - } - return { name: 'Repository', id: 'repo-unknown' }; - } - - /** - * Extract code entities (Classes, Functions, Routes, Models, Modules) - */ - async extractEntities(filePath, content = null) { - const entities = []; - let fileContent = content; - if (fileContent === null && fs.existsSync(filePath)) { - try { - fileContent = fs.readFileSync(filePath, 'utf8'); - } catch { - return entities; - } - } - if (!fileContent) return entities; - - const repo = this._getRepoForFile(filePath); - const repoPrefix = repo.name || 'code'; - const relPath = repo.path ? path.relative(repo.path, filePath).replace(/\\/g, '/') : path.basename(filePath); - const ext = path.extname(filePath).toLowerCase(); - - // 1. File / Module Entity - entities.push({ - name: `${repoPrefix}/${relPath}`, - canonical_name: `${repoPrefix}/${relPath}`, - type: 'CodeModule', - properties: { - repoId: repo.id, - repoName: repoPrefix, - filePath: relPath, - absolutePath: filePath, - language: ext.replace('.', ''), - lines: fileContent.split('\n').length - } - }); - - const lines = fileContent.split('\n'); - - // 2. JS / TS Parsing - if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) { - this._extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities); - } - // 3. Python Parsing - else if (ext === '.py') { - this._extractPythonEntities(lines, repoPrefix, relPath, filePath, entities); - } - // 4. Generic (Go, Rust, etc.) - else { - this._extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities); - } - - return entities; - } - - _extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities) { - const classRegex = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)(?:\s+extends\s+([A-Za-z0-9_$]+))?/g; - const interfaceRegex = /(?:export\s+)?interface\s+([A-Za-z0-9_$]+)/g; - const funcRegex = /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/g; - const constFuncRegex = /(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/g; - const routeRegex = /(?:app|router|server)\.(get|post|put|delete|patch|options|use)\s*\(\s*['"`]([^'"`]+)['"`]/gi; - const modelRegex = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:mongoose\.model|new\s+Schema|model)\s*\(/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; - - // Classes - let match; - classRegex.lastIndex = 0; - while ((match = classRegex.exec(lineText)) !== null) { - const className = match[1]; - entities.push({ - name: className, - canonical_name: `${repoPrefix}::${className}`, - type: 'CodeClass', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - extends: match[2] || null, - kind: 'class' - } - }); - } - - // Interfaces - interfaceRegex.lastIndex = 0; - while ((match = interfaceRegex.exec(lineText)) !== null) { - const ifaceName = match[1]; - entities.push({ - name: ifaceName, - canonical_name: `${repoPrefix}::${ifaceName}`, - type: 'CodeInterface', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - kind: 'interface' - } - }); - } - - // Functions - funcRegex.lastIndex = 0; - while ((match = funcRegex.exec(lineText)) !== null) { - const funcName = match[1]; - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName, - kind: 'function' - } - }); - } - - // Const Arrow Functions - constFuncRegex.lastIndex = 0; - while ((match = constFuncRegex.exec(lineText)) !== null) { - const funcName = match[1]; - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName, - kind: 'arrow_function' - } - }); - } - - // API Endpoints - routeRegex.lastIndex = 0; - while ((match = routeRegex.exec(lineText)) !== null) { - const method = match[1].toUpperCase(); - const routePath = match[2]; - entities.push({ - name: `${method} ${routePath}`, - canonical_name: `${method} ${routePath}`, - type: 'APIEndpoint', - properties: { - method, - route: routePath, - filePath: relPath, - line: lineNum - } - }); - } - - // Models - modelRegex.lastIndex = 0; - while ((match = modelRegex.exec(lineText)) !== null) { - const modelName = match[1]; - entities.push({ - name: modelName, - canonical_name: `${repoPrefix}::model::${modelName}`, - type: 'DBModel', - properties: { - filePath: relPath, - line: lineNum, - kind: 'schema_model' - } - }); - } - }); - } - - _extractPythonEntities(lines, repoPrefix, relPath, filePath, entities) { - const classRegex = /^class\s+([A-Za-z0-9_$]+)(?:\(([^)]+)\))?:/g; - const defRegex = /^(?:\s+)?def\s+([A-Za-z0-9_$]+)\s*\(/g; - const routeRegex = /@(?:app|router|api)\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - if (trimmed.startsWith('#')) return; - - let match; - classRegex.lastIndex = 0; - while ((match = classRegex.exec(trimmed)) !== null) { - const className = match[1]; - entities.push({ - name: className, - canonical_name: `${repoPrefix}::${className}`, - type: 'CodeClass', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - baseClass: match[2] || null - } - }); - } - - defRegex.lastIndex = 0; - while ((match = defRegex.exec(trimmed)) !== null) { - const funcName = match[1]; - if (!funcName.startsWith('__')) { - entities.push({ - name: `${funcName}()`, - canonical_name: `${repoPrefix}::${relPath}::${funcName}`, - type: 'CodeFunction', - properties: { - filePath: relPath, - absolutePath: filePath, - line: lineNum, - symbol: funcName - } - }); - } - } - - routeRegex.lastIndex = 0; - while ((match = routeRegex.exec(trimmed)) !== null) { - const method = match[1].toUpperCase(); - const routePath = match[2]; - entities.push({ - name: `${method} ${routePath}`, - canonical_name: `${method} ${routePath}`, - type: 'APIEndpoint', - properties: { - method, - route: routePath, - filePath: relPath, - line: lineNum - } - }); - } - }); - } - - _extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities) { - const goFuncRegex = /^func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_$]+)\s*\(/g; - const goStructRegex = /^type\s+([A-Za-z0-9_$]+)\s+struct/g; - const rustFnRegex = /^pub\s+fn\s+([A-Za-z0-9_$]+)\s*\(/g; - const rustStructRegex = /^pub\s+struct\s+([A-Za-z0-9_$]+)/g; - - lines.forEach((lineText, index) => { - const lineNum = index + 1; - const trimmed = lineText.trim(); - let match; - - if (ext === '.go') { - goFuncRegex.lastIndex = 0; - if ((match = goFuncRegex.exec(trimmed)) !== null) { - entities.push({ - name: `${match[1]}()`, - canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, - type: 'CodeFunction', - properties: { filePath: relPath, line: lineNum, symbol: match[1] } - }); - } - goStructRegex.lastIndex = 0; - if ((match = goStructRegex.exec(trimmed)) !== null) { - entities.push({ - name: match[1], - canonical_name: `${repoPrefix}::${match[1]}`, - type: 'CodeClass', - properties: { filePath: relPath, line: lineNum } - }); - } - } else if (ext === '.rs') { - rustFnRegex.lastIndex = 0; - if ((match = rustFnRegex.exec(trimmed)) !== null) { - entities.push({ - name: `${match[1]}()`, - canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, - type: 'CodeFunction', - properties: { filePath: relPath, line: lineNum, symbol: match[1] } - }); - } - rustStructRegex.lastIndex = 0; - if ((match = rustStructRegex.exec(trimmed)) !== null) { - entities.push({ - name: match[1], - canonical_name: `${repoPrefix}::${match[1]}`, - type: 'CodeClass', - properties: { filePath: relPath, line: lineNum } - }); - } - } - }); - } - - /** - * Extract relationships (CONTAINS, IMPORTS) - */ - async extractRelationships(filePath, content = null) { - const relationships = []; - let fileContent = content; - if (fileContent === null && fs.existsSync(filePath)) { - try { - fileContent = fs.readFileSync(filePath, 'utf8'); - } catch { - return relationships; - } - } - if (!fileContent) return relationships; - - const repo = this._getRepoForFile(filePath); - const repoPrefix = repo.name || 'code'; - const relPath = repo.path ? path.relative(repo.path, filePath).replace(/\\/g, '/') : path.basename(filePath); - const moduleName = `${repoPrefix}/${relPath}`; - - // 1. Repo -> Module CONTAINS - relationships.push({ - source_name: repoPrefix, - source_type: 'Repo', - target_name: moduleName, - target_type: 'CodeModule', - type: 'CONTAINS', - weight: 1.0, - confidence: 1.0 - }); - - // 2. Extract Import relationships - const importRegex = /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g; - let match; - while ((match = importRegex.exec(fileContent)) !== null) { - const targetImport = match[1] || match[2]; - if (targetImport && (targetImport.startsWith('.') || targetImport.startsWith('/'))) { - relationships.push({ - source_name: moduleName, - source_type: 'CodeModule', - target_name: targetImport, - target_type: 'CodeModule', - type: 'IMPORTS', - weight: 0.8, - confidence: 0.9 - }); - } - } - - return relationships; - } - - async extractEvidence(filePath, _content = null) { - return []; - } - - extractMetadata(filePath, _content = null) { - return { - source: 'code', - path: filePath - }; - } -} - -module.exports = CodeKnowledgeSource; diff --git a/ai/graph/sources/code/CodeEvidenceBuilder.js b/ai/graph/sources/code/CodeEvidenceBuilder.js new file mode 100644 index 00000000..20337a17 --- /dev/null +++ b/ai/graph/sources/code/CodeEvidenceBuilder.js @@ -0,0 +1,86 @@ +/** + * CodeEvidenceBuilder + * Generates deterministic evidence records for code-derived graph relationships. + * Every relationship from a repository must have traceable provenance. + */ + +class CodeEvidenceBuilder { + /** + * Build a list of evidence objects for a set of code relationships. + * + * @param {object} params + * @param {string} params.repoId - repository ID + * @param {string} params.repoName - repository display name + * @param {string} params.filePath - absolute path to the source file + * @param {string} params.relPath - workspace-relative path (repoName/src/file.ts) + * @param {Array} params.relationships - raw relationships from extractor + * @returns {Array<{relationship: object, evidence: object}>} + */ + buildForRelationships({ repoId, repoName, filePath, relPath, relationships }) { + const result = []; + + for (const rel of relationships) { + const evidence = this._evidenceForRelationship({ + repoId, + repoName, + filePath, + relPath, + rel + }); + result.push({ relationship: rel, evidence }); + } + + return result; + } + + /** + * Build evidence for a single relationship. + */ + _evidenceForRelationship({ repoId, repoName, filePath, relPath, rel }) { + // Construct a human-readable raw sentence describing where this relationship comes from + const rawSentence = this._describeRelationship(rel, relPath, repoName); + + return { + sourceId: filePath, // absolute file path — consistent with note evidence + extractor: 'code', // deterministic, not AI + subjectText: rel.source_name, + predicateText: rel.type, + objectText: rel.target_name, + rawSentence, + confidence: rel.confidence || 1.0, + // Additional code-specific provenance metadata stored in rawSentence + _meta: { + repoId, + repoName, + relPath, + relationshipType: rel.type, + sourceType: rel.source_type, + targetType: rel.target_type + } + }; + } + + _describeRelationship(rel, relPath, repoName) { + const src = rel.source_name; + const tgt = rel.target_name; + + switch (rel.type) { + case 'CONTAINS': + return `${src} contains ${tgt} in ${repoName}/${relPath}`; + case 'IMPORTS': + return `${src} imports ${tgt} in ${relPath}`; + case 'EXTENDS': + return `${src} extends ${tgt} in ${relPath}`; + case 'IMPLEMENTS': + return `${src} implements ${tgt} in ${relPath}`; + case 'CALLS': + return `${src} calls ${tgt} in ${relPath}`; + case 'DEFINES_ROUTE': + return `${src} defines route ${tgt} in ${relPath}`; + default: + return `${src} ${rel.type} ${tgt} in ${repoName}/${relPath}`; + } + } +} + +module.exports = { CodeEvidenceBuilder }; diff --git a/ai/graph/sources/code/CodeKnowledgeSource.js b/ai/graph/sources/code/CodeKnowledgeSource.js new file mode 100644 index 00000000..66c956ba --- /dev/null +++ b/ai/graph/sources/code/CodeKnowledgeSource.js @@ -0,0 +1,379 @@ +/** + * CodeKnowledgeSource + * KnowledgeSource implementation that indexes code repositories. + * + * Orchestrates: + * RepositoryScanner — file discovery + * TreeSitterCodeParser — AST parsing (with regex fallback) + * CodeEvidenceBuilder — provenance for every relationship + */ + +const fs = require('fs'); +const path = require('path'); +const KnowledgeSource = require('../KnowledgeSource'); +const { RepositoryScanner } = require('./RepositoryScanner'); +const { CodeEvidenceBuilder } = require('./CodeEvidenceBuilder'); +const treeSitterParser = require('./TreeSitterCodeParser'); + +class CodeKnowledgeSource extends KnowledgeSource { + /** + * @param {Array<{id: string, name: string, path: string}>} attachedRepos + */ + constructor(attachedRepos = []) { + super(); + this.attachedRepos = Array.isArray(attachedRepos) ? attachedRepos : []; + this._scanner = new RepositoryScanner(); + this._evidenceBuilder = new CodeEvidenceBuilder(); + this._astCache = new Map(); + } + + sourceType() { return 'code'; } + baseConfidence() { return 0.95; } + + supports(filePath) { + return this._scanner.supports(filePath); + } + + // ─── Discovery ───────────────────────────────────────────────────────────── + + /** + * Discover all code files from all attached repositories. + * Falls back to reading metadata.json when no repos were passed directly. + */ + discover(workspaceRoot) { + const repos = this._resolveRepos(workspaceRoot); + const discovered = []; + + for (const repo of repos) { + if (!repo || !repo.path || !fs.existsSync(repo.path)) continue; + const files = this._scanner.scan(repo.path); + discovered.push(...files); + } + + return discovered; + } + + _resolveRepos(workspaceRoot) { + if (this.attachedRepos.length > 0) return this.attachedRepos; + + // Fallback: load from workspace metadata.json + if (!workspaceRoot) return []; + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(meta.attachedRepos)) { + // Also populate this.attachedRepos so _getRepoForFile works correctly + this.attachedRepos = meta.attachedRepos; + return this.attachedRepos; + } + } + } catch { /* ignore read errors */ } + return []; + } + + // ─── Repo resolution ─────────────────────────────────────────────────────── + + /** + * Find the repo entry that owns filePath. + * Returns null if the file doesn't belong to any attached repo (prevents phantom provenance). + */ + _getRepoForFile(filePath) { + const normalized = path.resolve(filePath); + for (const repo of this.attachedRepos) { + if (repo && repo.path && normalized.startsWith(path.resolve(repo.path))) { + return repo; + } + } + return null; // not null-coalesced to a fake repo — let callers handle this + } + + // ─── AST cache ───────────────────────────────────────────────────────────── + + async _getAstParsed(filePath, fileContent, repoPrefix, relPath) { + const cacheKey = `${filePath}:${fileContent.length}`; + if (this._astCache.has(cacheKey)) return this._astCache.get(cacheKey); + + const result = await treeSitterParser.parse(filePath, fileContent, repoPrefix, relPath); + if (result) this._astCache.set(cacheKey, result); + return result; + } + + // ─── Entities ────────────────────────────────────────────────────────────── + + async extractEntities(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + const repoPrefix = repo?.name || 'code'; + const relPath = repo?.path + ? path.relative(repo.path, filePath).replace(/\\/g, '/') + : path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + + const moduleEntity = { + name: `${repoPrefix}/${relPath}`, + canonical_name: `${repoPrefix}/${relPath}`, + type: 'CodeModule', + properties: { + repoId: repo?.id || 'unknown', + repoName: repoPrefix, + filePath: relPath, + absolutePath: filePath, + language: ext.replace('.', ''), + lines: fileContent.split('\n').length + } + }; + + // Prefer Tree-Sitter AST + const astResult = await this._getAstParsed(filePath, fileContent, repoPrefix, relPath); + if (astResult?.entities?.length > 0) { + return [moduleEntity, ...astResult.entities]; + } + + // Regex fallback + const entities = [moduleEntity]; + const lines = fileContent.split('\n'); + if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) { + this._extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities); + } else if (ext === '.py') { + this._extractPythonEntities(lines, repoPrefix, relPath, filePath, entities); + } else { + this._extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities); + } + + return entities; + } + + // ─── Relationships ───────────────────────────────────────────────────────── + + async extractRelationships(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + const repoPrefix = repo?.name || 'code'; + const relPath = repo?.path + ? path.relative(repo.path, filePath).replace(/\\/g, '/') + : path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const moduleName = `${repoPrefix}/${relPath}`; + + const relationships = [ + { + source_name: repoPrefix, + source_type: 'Repo', + target_name: moduleName, + target_type: 'CodeModule', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + } + ]; + + // Prefer Tree-Sitter AST + const astResult = await this._getAstParsed(filePath, fileContent, repoPrefix, relPath); + if (astResult?.relationships?.length > 0) { + relationships.push(...astResult.relationships); + return relationships; + } + + // Regex fallback: import detection + const importRegex = /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g; + let match; + while ((match = importRegex.exec(fileContent)) !== null) { + const target = match[1] || match[2]; + if (target && target.startsWith('.')) { + const dir = path.dirname(relPath).replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, target)); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}${ext}`; + } + const targetName = `${repoPrefix}/${resolvedRel}`; + relationships.push({ + source_name: moduleName, + source_type: 'CodeModule', + target_name: targetName, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.9 + }); + } + } + + return relationships; + } + + // ─── Evidence ────────────────────────────────────────────────────────────── + + /** + * Produce evidence records for every relationship in this file. + * This is what gives code relationships real provenance in the graph. + */ + async extractEvidence(filePath, content = null) { + const fileContent = content ?? this._readFile(filePath); + if (!fileContent) return []; + + const repo = this._getRepoForFile(filePath); + if (!repo) return []; // no provenance if file doesn't match a known repo + + const relPath = path.relative(repo.path, filePath).replace(/\\/g, '/'); + const relationships = await this.extractRelationships(filePath, fileContent); + + const pairs = this._evidenceBuilder.buildForRelationships({ + repoId: repo.id, + repoName: repo.name, + filePath, + relPath, + relationships + }); + + return pairs.map(p => p.evidence); + } + + extractMetadata(filePath) { + const repo = this._getRepoForFile(filePath); + return { + source: 'code', + sourceType: 'repository', + repoId: repo?.id || null, + repoName: repo?.name || null, + path: filePath + }; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + _readFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) return null; + try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; } + } + + _extractJsTsEntities(lines, repoPrefix, relPath, filePath, entities) { + const classRegex = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)(?:\s+extends\s+([A-Za-z0-9_$]+))?/g; + const interfaceRegex = /(?:export\s+)?interface\s+([A-Za-z0-9_$]+)/g; + const funcRegex = /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/g; + const constFuncRegex = /(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/g; + const routeRegex = /(?:app|router|server)\.(get|post|put|delete|patch|options|use)\s*\(\s*['"`]([^'"`]+)['"`]/gi; + const modelRegex = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:mongoose\.model|new\s+Schema|model)\s*\(/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return; + + let match; + classRegex.lastIndex = 0; + while ((match = classRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, extends: match[2] || null, kind: 'class' } }); + } + + interfaceRegex.lastIndex = 0; + while ((match = interfaceRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeInterface', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, kind: 'interface' } }); + } + + funcRegex.lastIndex = 0; + while ((match = funcRegex.exec(lineText)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1], kind: 'function' } }); + } + + constFuncRegex.lastIndex = 0; + while ((match = constFuncRegex.exec(lineText)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1], kind: 'arrow_function' } }); + } + + routeRegex.lastIndex = 0; + while ((match = routeRegex.exec(lineText)) !== null) { + const method = match[1].toUpperCase(); + entities.push({ name: `${method} ${match[2]}`, canonical_name: `${method} ${match[2]}`, type: 'APIEndpoint', + properties: { method, route: match[2], filePath: relPath, line: lineNum } }); + } + + modelRegex.lastIndex = 0; + while ((match = modelRegex.exec(lineText)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::model::${match[1]}`, type: 'DBModel', + properties: { filePath: relPath, line: lineNum, kind: 'schema_model' } }); + } + }); + } + + _extractPythonEntities(lines, repoPrefix, relPath, filePath, entities) { + const classRegex = /^class\s+([A-Za-z0-9_$]+)(?:\(([^)]+)\))?:/g; + const defRegex = /^(?:\s+)?def\s+([A-Za-z0-9_$]+)\s*\(/g; + const routeRegex = /@(?:app|router|api)\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + if (trimmed.startsWith('#')) return; + + let match; + classRegex.lastIndex = 0; + while ((match = classRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, baseClass: match[2] || null } }); + } + + defRegex.lastIndex = 0; + while ((match = defRegex.exec(trimmed)) !== null) { + if (!match[1].startsWith('__')) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, absolutePath: filePath, line: lineNum, symbol: match[1] } }); + } + } + + routeRegex.lastIndex = 0; + while ((match = routeRegex.exec(trimmed)) !== null) { + const method = match[1].toUpperCase(); + entities.push({ name: `${method} ${match[2]}`, canonical_name: `${method} ${match[2]}`, type: 'APIEndpoint', + properties: { method, route: match[2], filePath: relPath, line: lineNum } }); + } + }); + } + + _extractGenericEntities(lines, repoPrefix, relPath, filePath, ext, entities) { + const goFuncRegex = /^func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_$]+)\s*\(/g; + const goStructRegex = /^type\s+([A-Za-z0-9_$]+)\s+struct/g; + const rustFnRegex = /^pub\s+fn\s+([A-Za-z0-9_$]+)\s*\(/g; + const rustStructRegex = /^pub\s+struct\s+([A-Za-z0-9_$]+)/g; + + lines.forEach((lineText, index) => { + const lineNum = index + 1; + const trimmed = lineText.trim(); + let match; + + if (ext === '.go') { + goFuncRegex.lastIndex = 0; + if ((match = goFuncRegex.exec(trimmed)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, line: lineNum, symbol: match[1] } }); + } + goStructRegex.lastIndex = 0; + if ((match = goStructRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, line: lineNum } }); + } + } else if (ext === '.rs') { + rustFnRegex.lastIndex = 0; + if ((match = rustFnRegex.exec(trimmed)) !== null) { + entities.push({ name: `${match[1]}()`, canonical_name: `${repoPrefix}::${relPath}::${match[1]}`, type: 'CodeFunction', + properties: { filePath: relPath, line: lineNum, symbol: match[1] } }); + } + rustStructRegex.lastIndex = 0; + if ((match = rustStructRegex.exec(trimmed)) !== null) { + entities.push({ name: match[1], canonical_name: `${repoPrefix}::${match[1]}`, type: 'CodeClass', + properties: { filePath: relPath, line: lineNum } }); + } + } + }); + } +} + +module.exports = CodeKnowledgeSource; diff --git a/ai/graph/sources/code/RepositoryScanner.js b/ai/graph/sources/code/RepositoryScanner.js new file mode 100644 index 00000000..9d81a3e3 --- /dev/null +++ b/ai/graph/sources/code/RepositoryScanner.js @@ -0,0 +1,156 @@ +/** + * RepositoryScanner + * Handles recursive file discovery within a repository, applying ignore rules. + */ + +const fs = require('fs'); +const path = require('path'); + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', 'dist', '__dist', 'dist__', '__dist__', '_dist', + 'build', '__build__', '_build', 'out', '__out__', '_out', + 'assets', 'chunks', 'bundle', 'bundles', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', + 'mocks', '__mocks__', 'fixtures', '__fixtures__', + 'cypress', 'playwright', 'e2e', + '.git', '.svn', '.hg', + 'vendor', 'bower_components', 'coverage', '.nyc_output', + '.next', '.nuxt', '.output', '.svelte-kit', '.vite', '.parcel-cache', '.turbo', '.webpack', '.rollup.cache', + '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', + '.venv', 'venv', 'env', '.env', 'virtualenv', '.conda', + 'bin', 'obj', 'target', 'pkg', + '.idea', '.vscode', '.vs', '.settings', '.cache', '.gradle', '.cargo', + 'tmp', '.tmp', 'temp', '.temp', 'logs', '.logs' +]); + +const IGNORED_DIR_PATTERNS = [ + /^__.*__$/, + /.*[-_.]dist.*/i, + /.*[-_.]build.*/i, + /.*[-_.]out.*/i, + /.*[-_.]cache.*/i, + /.*[-_.]test(s)?$/i, + /.*[-_.]spec(s)?$/i, + /^\.egg-info$/, + /.*\.egg-info$/i +]; + +const IGNORED_FILE_SUFFIXES = [ + '.min.js', '.min.mjs', '.min.cjs', '.min.css', + '.bundle.js', '.chunk.js', '.chunk.mjs', + '.map', '.d.ts.map', '.d.ts', + // Test and spec file suffixes + '.test.js', '.test.jsx', '.test.ts', '.test.tsx', '.test.mjs', '.test.cjs', + '.spec.js', '.spec.jsx', '.spec.ts', '.spec.tsx', '.spec.mjs', '.spec.cjs', + '.cy.js', '.cy.ts', '.cy.jsx', '.cy.tsx', + '.stories.jsx', '.stories.tsx', '.stories.js', '.stories.ts', + '_test.go' +]; + +const IGNORED_FILE_PATTERNS = [ + /^test_.*\.py$/i, + /.*_test\.py$/i, + /\.[a-zA-Z0-9_-]{8,16}\.(js|mjs|cjs|css)$/i +]; + +const CODE_EXTENSIONS = new Set([ + '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', + '.py', + '.go', + '.rs', + '.java', + '.c', '.cpp', '.h', '.hpp', '.cc', '.cs', + '.rb', '.php', + '.vue', '.svelte', + '.lua', '.kt', '.swift', '.scala', '.sh', '.bash' +]); + +const MAX_FILE_SIZE_BYTES = 512 * 1024; // 512 KB + +class RepositoryScanner { + /** + * @param {object} options + * @param {Set} [options.ignoredDirs] - directory names to skip + * @param {Set} [options.codeExtensions] - file extensions to include + * @param {number} [options.maxFiles] - cap on discovered file count + * @param {number} [options.maxDepth] - max directory traversal depth + * @param {number} [options.maxFileSizeBytes] + */ + constructor(options = {}) { + this.ignoredDirs = options.ignoredDirs || IGNORED_DIRECTORIES; + this.codeExtensions = options.codeExtensions || CODE_EXTENSIONS; + this.maxFiles = options.maxFiles || 1500; + this.maxDepth = options.maxDepth || 12; + this.maxFileSizeBytes = options.maxFileSizeBytes || MAX_FILE_SIZE_BYTES; + } + + /** + * Returns true if this scanner supports the given file extension. + * @param {string} filePath + */ + supports(filePath) { + if (!filePath || typeof filePath !== 'string') return false; + const lower = path.basename(filePath).toLowerCase(); + const ext = path.extname(filePath).toLowerCase(); + if (!this.codeExtensions.has(ext)) return false; + if (IGNORED_FILE_SUFFIXES.some(s => lower.endsWith(s))) return false; + if (IGNORED_FILE_PATTERNS.some(p => p.test(lower))) return false; + return true; + } + + /** + * Walk a repository root and return absolute paths of all indexable code files. + * @param {string} repoPath - absolute path to repository root + * @returns {string[]} + */ + scan(repoPath) { + if (!repoPath || !fs.existsSync(repoPath)) return []; + const result = []; + try { + this._walk(repoPath, result, 0); + } catch (err) { + console.warn(`[RepositoryScanner] Failed scanning ${repoPath}:`, err.message); + } + return result; + } + + _walk(dir, result, depth) { + if (depth > this.maxDepth || result.length >= this.maxFiles) return; + + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (result.length >= this.maxFiles) break; + + const lowerName = entry.name.toLowerCase(); + + // Skip hidden dirs (except .github, etc.) and explicitly ignored dirs/patterns + if (entry.isDirectory()) { + if (this.ignoredDirs.has(entry.name) || this.ignoredDirs.has(lowerName) || entry.name.startsWith('.')) continue; + const matchesPattern = IGNORED_DIR_PATTERNS.some(p => p.test(lowerName)); + if (matchesPattern) continue; + + this._walk(path.join(dir, entry.name), result, depth + 1); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (!this.codeExtensions.has(ext)) continue; + if (IGNORED_FILE_SUFFIXES.some(s => lowerName.endsWith(s))) continue; + if (IGNORED_FILE_PATTERNS.some(p => p.test(lowerName))) continue; + + const fullPath = path.join(dir, entry.name); + try { + if (fs.statSync(fullPath).size <= this.maxFileSizeBytes) { + result.push(fullPath); + } + } catch { /* skip inaccessible */ } + } + } + } +} + +module.exports = { RepositoryScanner, IGNORED_DIRECTORIES, CODE_EXTENSIONS }; diff --git a/ai/graph/sources/code/TreeSitterCodeParser.js b/ai/graph/sources/code/TreeSitterCodeParser.js new file mode 100644 index 00000000..047cc3d9 --- /dev/null +++ b/ai/graph/sources/code/TreeSitterCodeParser.js @@ -0,0 +1,602 @@ +/** + * TreeSitterCodeParser + * Multi-language AST parser powered by web-tree-sitter and WebAssembly grammars. + * 100% self-contained in Node.js / Electron. + */ + +const path = require('path'); +let Parser = null; +try { + Parser = require('web-tree-sitter'); +} catch { + Parser = null; +} + +const EXT_TO_WASM = { + '.js': 'javascript', + '.jsx': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.ts': 'typescript', + '.tsx': 'tsx', + '.py': 'python', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.hpp': 'cpp', + '.cc': 'cpp', + '.cs': 'c_sharp', + '.rb': 'ruby', + '.php': 'php', + '.vue': 'vue', + '.lua': 'lua', + '.kt': 'kotlin', + '.swift': 'swift', + '.scala': 'scala', + '.sh': 'bash', + '.bash': 'bash' +}; + +class TreeSitterCodeParser { + constructor() { + this._initialized = false; + this._initPromise = null; + this._languages = new Map(); + this._parser = null; + } + + async initialize() { + if (this._initialized) return true; + if (this._initPromise) return this._initPromise; + + this._initPromise = (async () => { + if (!Parser) return false; + try { + await Parser.init(); + this._parser = new Parser(); + this._initialized = true; + return true; + } catch (err) { + console.warn('[TreeSitterCodeParser] Initialization failed:', err.message); + return false; + } + })(); + + return this._initPromise; + } + + async getLanguage(ext) { + const wasmName = EXT_TO_WASM[ext.toLowerCase()]; + if (!wasmName) return null; + + if (this._languages.has(wasmName)) { + return this._languages.get(wasmName); + } + + try { + const wasmPath = require.resolve(`tree-sitter-wasms/out/tree-sitter-${wasmName}.wasm`); + const lang = await Parser.Language.load(wasmPath); + this._languages.set(wasmName, lang); + return lang; + } catch (err) { + console.warn(`[TreeSitterCodeParser] Failed loading grammar for ${wasmName}:`, err.message); + return null; + } + } + + /** + * Parse code content into structured AST entities & relationships. + */ + async parse(filePath, content, repoPrefix = 'code', relPath = '') { + const ext = path.extname(filePath).toLowerCase(); + const ok = await this.initialize(); + if (!ok) return null; + + const lang = await this.getLanguage(ext); + if (!lang) return null; + + try { + this._parser.setLanguage(lang); + const tree = this._parser.parse(content); + const moduleName = `${repoPrefix}/${relPath || path.basename(filePath)}`; + + const entities = []; + const relationships = []; + const calls = new Set(); + const declaredFunctions = new Set(); + + this._walk(tree.rootNode, { + ext, + filePath, + relPath, + repoPrefix, + moduleName, + entities, + relationships, + calls, + declaredFunctions, + currentClass: null, + currentFunction: null + }); + + // Match calls to declared functions within file + for (const callName of calls) { + if (declaredFunctions.has(callName)) { + relationships.push({ + source_name: moduleName, + source_type: 'CodeModule', + target_name: `${callName}()`, + target_type: 'CodeFunction', + type: 'CALLS', + weight: 0.7, + confidence: 0.85 + }); + } + } + + return { entities, relationships }; + } catch (err) { + console.warn(`[TreeSitterCodeParser] AST parsing error on ${filePath}:`, err.message); + return null; + } + } + + _walk(node, ctx) { + if (!node) return; + const type = node.type; + const startLine = node.startPosition.row + 1; + + // 1. JavaScript / TypeScript AST + if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ctx.ext)) { + if (type === 'class_declaration' || type === 'class') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const className = nameNode.text; + const heritage = node.children.find(c => c.type === 'class_heritage'); + const extendsName = heritage ? heritage.text.replace(/^extends\s+/, '').trim() : null; + + ctx.entities.push({ + name: className, + canonical_name: `${ctx.repoPrefix}::${className}`, + type: 'CodeClass', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + extends: extendsName, + kind: 'class' + } + }); + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: className, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + if (extendsName) { + ctx.relationships.push({ + source_name: className, + source_type: 'CodeClass', + target_name: extendsName, + target_type: 'CodeClass', + type: 'EXTENDS', + weight: 0.9, + confidence: 0.95 + }); + } + + const prevClass = ctx.currentClass; + ctx.currentClass = className; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentClass = prevClass; + return; + } + } + + if (type === 'interface_declaration') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const ifaceName = nameNode.text; + ctx.entities.push({ + name: ifaceName, + canonical_name: `${ctx.repoPrefix}::${ifaceName}`, + type: 'CodeInterface', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + kind: 'interface' + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: ifaceName, + target_type: 'CodeInterface', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + + if (type === 'function_declaration' || type === 'method_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + symbol: funcName, + memberOf: ctx.currentClass || null, + kind: type === 'method_definition' ? 'method' : 'function' + } + }); + + const parentSource = ctx.currentClass || ctx.moduleName; + const parentType = ctx.currentClass ? 'CodeClass' : 'CodeModule'; + ctx.relationships.push({ + source_name: parentSource, + source_type: parentType, + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + const prevFunc = ctx.currentFunction; + ctx.currentFunction = funcName; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentFunction = prevFunc; + return; + } + } + + if (type === 'lexical_declaration' || type === 'variable_declaration') { + for (const decl of node.namedChildren) { + if (decl.type === 'variable_declarator') { + const idNode = decl.childForFieldName('name'); + const initNode = decl.childForFieldName('value'); + if (idNode && initNode) { + if (initNode.type === 'arrow_function' || initNode.type === 'function') { + const funcName = idNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: decl.startPosition.row + 1, + symbol: funcName, + kind: 'arrow_function' + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + } + } + + if (type === 'call_expression') { + const fnNode = node.childForFieldName('function'); + if (fnNode) { + const fnText = fnNode.text; + // Check route handlers: app.get('/...', ...), router.post('/...', ...) + if (fnText.includes('.')) { + const parts = fnText.split('.'); + const method = parts[parts.length - 1].toLowerCase(); + if (['get', 'post', 'put', 'delete', 'patch', 'use'].includes(method)) { + const argsNode = node.childForFieldName('arguments'); + if (argsNode && argsNode.namedChildCount > 0) { + const firstArg = argsNode.namedChild(0); + if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) { + const routePath = firstArg.text.replace(/['"`]/g, ''); + const epName = `${method.toUpperCase()} ${routePath}`; + ctx.entities.push({ + name: epName, + canonical_name: epName, + type: 'APIEndpoint', + properties: { + method: method.toUpperCase(), + route: routePath, + filePath: ctx.relPath, + line: startLine + } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: epName, + target_type: 'APIEndpoint', + type: 'DEFINES_ROUTE', + weight: 1.0, + confidence: 0.95 + }); + } + } + } + } else { + ctx.calls.add(fnText); + } + } + } + + if (type === 'import_statement') { + const sourceNode = node.childForFieldName('source'); + if (sourceNode) { + const importRaw = sourceNode.text.replace(/['"]/g, '').trim(); + if (importRaw.startsWith('.')) { + const dir = path.dirname(ctx.relPath || '').replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, importRaw)); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}${ctx.ext}`; + } + const targetName = `${ctx.repoPrefix}/${resolvedRel}`; + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: targetName, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } + } + } + } + + // 2. Python AST + else if (ctx.ext === '.py') { + if (type === 'class_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const className = nameNode.text; + const superclasses = node.childForFieldName('superclasses'); + const baseName = superclasses ? superclasses.text.replace(/[()]/g, '').trim() : null; + + ctx.entities.push({ + name: className, + canonical_name: `${ctx.repoPrefix}::${className}`, + type: 'CodeClass', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + baseClass: baseName, + kind: 'class' + } + }); + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: className, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + if (baseName) { + ctx.relationships.push({ + source_name: className, + source_type: 'CodeClass', + target_name: baseName, + target_type: 'CodeClass', + type: 'EXTENDS', + weight: 0.9, + confidence: 0.95 + }); + } + + const prevClass = ctx.currentClass; + ctx.currentClass = className; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentClass = prevClass; + return; + } + } + + if (type === 'function_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.declaredFunctions.add(funcName); + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { + filePath: ctx.relPath, + absolutePath: ctx.filePath, + line: startLine, + symbol: funcName, + memberOf: ctx.currentClass || null + } + }); + + const parentSource = ctx.currentClass || ctx.moduleName; + const parentType = ctx.currentClass ? 'CodeClass' : 'CodeModule'; + ctx.relationships.push({ + source_name: parentSource, + source_type: parentType, + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + + const prevFunc = ctx.currentFunction; + ctx.currentFunction = funcName; + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + ctx.currentFunction = prevFunc; + return; + } + } + + if (type === 'import_from_statement' || type === 'import_statement') { + const modNode = node.childForFieldName('module_name') || node.childForFieldName('name'); + if (modNode) { + const importRaw = modNode.text.trim(); + if (importRaw.startsWith('.')) { + const dir = path.dirname(ctx.relPath || '').replace(/\\/g, '/'); + let resolvedRel = path.posix.normalize(path.posix.join(dir === '.' ? '' : dir, importRaw.replace(/\./g, '/'))); + if (!path.extname(resolvedRel)) { + resolvedRel = `${resolvedRel}.py`; + } + const targetName = `${ctx.repoPrefix}/${resolvedRel}`; + + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: targetName, + target_type: 'CodeModule', + type: 'IMPORTS', + weight: 0.8, + confidence: 0.95 + }); + } + } + } + } + + // 3. Go AST + else if (ctx.ext === '.go') { + if (type === 'function_declaration' || type === 'method_declaration') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { filePath: ctx.relPath, line: startLine, symbol: funcName } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } else if (type === 'type_declaration') { + for (const spec of node.namedChildren) { + if (spec.type === 'type_spec') { + const nameNode = spec.childForFieldName('name'); + if (nameNode) { + const typeName = nameNode.text; + ctx.entities.push({ + name: typeName, + canonical_name: `${ctx.repoPrefix}::${typeName}`, + type: 'CodeClass', + properties: { filePath: ctx.relPath, line: startLine } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: typeName, + target_type: 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + } + } + + // 4. Rust AST + else if (ctx.ext === '.rs') { + if (type === 'function_item') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const funcName = nameNode.text; + ctx.entities.push({ + name: `${funcName}()`, + canonical_name: `${ctx.repoPrefix}::${ctx.relPath}::${funcName}`, + type: 'CodeFunction', + properties: { filePath: ctx.relPath, line: startLine, symbol: funcName } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: `${funcName}()`, + target_type: 'CodeFunction', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } else if (type === 'struct_item' || type === 'enum_item' || type === 'trait_item') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const typeName = nameNode.text; + ctx.entities.push({ + name: typeName, + canonical_name: `${ctx.repoPrefix}::${typeName}`, + type: type === 'trait_item' ? 'CodeInterface' : 'CodeClass', + properties: { filePath: ctx.relPath, line: startLine } + }); + ctx.relationships.push({ + source_name: ctx.moduleName, + source_type: 'CodeModule', + target_name: typeName, + target_type: type === 'trait_item' ? 'CodeInterface' : 'CodeClass', + type: 'CONTAINS', + weight: 1.0, + confidence: 1.0 + }); + } + } + } + + // Walk children + for (let i = 0; i < node.namedChildCount; i++) { + this._walk(node.namedChild(i), ctx); + } + } +} + +const parserInstance = new TreeSitterCodeParser(); +module.exports = parserInstance; diff --git a/ai/graph/sources/code/index.js b/ai/graph/sources/code/index.js new file mode 100644 index 00000000..f79ddc9e --- /dev/null +++ b/ai/graph/sources/code/index.js @@ -0,0 +1,11 @@ +const CodeKnowledgeSource = require('./CodeKnowledgeSource'); +const { RepositoryScanner } = require('./RepositoryScanner'); +const { CodeEvidenceBuilder } = require('./CodeEvidenceBuilder'); +const treeSitterParser = require('./TreeSitterCodeParser'); + +module.exports = { + CodeKnowledgeSource, + RepositoryScanner, + CodeEvidenceBuilder, + treeSitterParser +}; diff --git a/ai/queue/GraphWorker.js b/ai/queue/GraphWorker.js index a73607c2..4247ae8b 100644 --- a/ai/queue/GraphWorker.js +++ b/ai/queue/GraphWorker.js @@ -64,6 +64,9 @@ class GraphWorker { // Run background maintenance when queue becomes empty try { await this.maintenance.runMaintenance(); + if (this.graphDb && typeof this.graphDb.recomputeLayout === 'function') { + this.graphDb.recomputeLayout(); + } } catch (mErr) { log.warn('Background GraphMaintenance pass skipped:', mErr.message); } @@ -98,17 +101,20 @@ class GraphWorker { const ExcalidrawKnowledgeSource = require('../graph/sources/ExcalidrawKnowledgeSource'); const DrawioKnowledgeSource = require('../graph/sources/DrawioKnowledgeSource'); const MermaidKnowledgeSource = require('../graph/sources/MermaidKnowledgeSource'); + const { CodeKnowledgeSource } = require('../graph/sources/code'); const EvidenceStore = require('../graph/EvidenceStore'); const registry = new KnowledgeSourceRegistry(); const excSrc = new ExcalidrawKnowledgeSource(); const drwSrc = new DrawioKnowledgeSource(); const mrmSrc = new MermaidKnowledgeSource(); + const codeSrc = new CodeKnowledgeSource(); let source = null; if (excSrc.supports(job.note_path)) source = excSrc; else if (drwSrc.supports(job.note_path)) source = drwSrc; else if (mrmSrc.supports(job.note_path)) source = mrmSrc; + else if (codeSrc.supports(job.note_path)) source = codeSrc; if (source && this.graphDb) { const { entities, relationships, evidence } = await registry.extract(source, job.note_path, content); diff --git a/docs/workspace/audio-recording.md b/docs/workspace/audio-recording.md index 7a1f5fa1..8b8a9768 100644 --- a/docs/workspace/audio-recording.md +++ b/docs/workspace/audio-recording.md @@ -41,16 +41,18 @@ Configure STT preferences in **AI -> AI Settings -> Speech-to-Text**: ## 3. Storage & Markdown Integration -- **Audio Files**: Stored in `{workspace}/media/audio/*.webm`. -- **Transcripts**: Stored alongside audio files in `{workspace}/media/audio/*.json`. -- **Note Insertion**: Inserting a recording places both the audio player and transcript block at your cursor: - ```markdown - ![Audio Recording](media/audio/recording-2026-09-26.webm) - ``` +- **Asset Bundles**: Audio and video recordings are stored inside isolated bundle directories: + - Audio: `{workspace}/media/audio//.webm` + - Companion Transcripts: `{workspace}/media/audio//transcript.json` +- **Editor Markdown Preview**: + - Audio files render interactive player cards with waveform visualizers. + - Companion transcripts render rich `.markdown-transcript-card` elements with View, Copy Text/Markdown, and Download actions. +- **Synchronized Transcript Drawer**: Clicking any audio or video recording with companion transcript data opens the media player alongside an interactive, timestamp-synchronized side drawer. --- -## 4. Diagrams & Media Gallery Integration +## 4. Diagrams & Media Asset Manager Integration -- Filter by the **Transcripts** and **Audio** categories in `Workspace -> Diagrams & Media Gallery` (`Ctrl/Cmd + Alt + M`). +- Filter by **Audio**, **Videos**, and **Transcripts** categories in `Workspace -> Diagrams & Media` (`Ctrl/Cmd + Alt + M`). - **On-Demand AI Transcript Generation (`✨`)**: Run speech-to-text on any existing recording without re-recording. +- **Reference Tracking**: View exactly which notes and lines reference the audio/video recordings and transcripts. diff --git a/docs/workspace/media.md b/docs/workspace/media.md index 2406a9d0..3fa73262 100644 --- a/docs/workspace/media.md +++ b/docs/workspace/media.md @@ -36,11 +36,13 @@ Keep your assets tidy using the Media Health Dashboard: --- -## 4. Diagrams & Media Gallery - -Notely provides a dedicated full-screen page under **Workspace > Diagrams & Media Gallery** (shortcut: `Ctrl+Alt+M` or `Cmd+Alt+M`): -- **Physical Disk Scanner & Orphan Detection**: Scans workspace folders (`media/`, `assets/`, `images/`) to identify files present on disk that have zero note references, letting you clean up orphaned files with the `⚠️ Unused / Orphans` filter tab. -- **Collapsible Category Filter**: Filter by category (Diagrams, Images, PDFs, Videos, Audio, Documents, Transcripts) and usage scope. -- **Referenced Notes & Line Jumping**: Click any item to inspect details and see all notes referencing it, complete with line numbers and markdown snippets. Click **Open Note** to jump directly to the note and line. -- **Audio & Media Preview**: Embedded audio player with waveform preview, video modal player, and expandable transcript drawer with action items and key points. -- **On-Demand AI Transcript Generation (`✨`)**: Run speech-to-text directly on any existing video or audio recording in the gallery. +## 4. Diagrams & Media Asset Manager + +Notely provides a dedicated full-screen asset manager under **Workspace > Diagrams & Media** (shortcut: `Ctrl+Alt+M` or `Cmd+Alt+M`): +- **Dense Asset Table Layout**: High-density table displaying Asset Name, Type Badge, File Size, Reference Status, and Quick Action buttons. +- **Fixed Sidebar & Category Filters**: Left sidebar (260px) with real-time counters for Diagrams, UI Prototypes, Images, PDFs, Videos, Audio, Transcripts, and Documents. +- **Physical Disk Scanner & Orphan Detection**: Discovers assets on disk across `media/`, `assets/`, and `images/`. Unreferenced assets are highlighted with the `⚠️ Unused / Orphans` badge and filter tab. +- **Asset Bundles**: Audio and video recordings are organized into bundle folders (`media/audio//` and `media/video//`) containing the primary recording alongside companion `transcript.json`. +- **Integrated Full-Screen Media Viewer**: Standardized modal viewer with 100% full-width layout, subtitle/transcript side drawer, playback speed controls, and copy/download tools. +- **Safe Asset Deletion**: Remove unwanted assets with safety confirmation modals that check note references and warn before removing actively linked files. +- **Referenced Notes & Line Jumping**: Inspect reference pills to view note names, line numbers, and markdown context snippets, with 1-click navigation to jump directly to the editor position. diff --git a/electron/ai/aiHandlers.cjs b/electron/ai/aiHandlers.cjs index 2326e554..aade5519 100644 --- a/electron/ai/aiHandlers.cjs +++ b/electron/ai/aiHandlers.cjs @@ -637,10 +637,12 @@ async function handleGetModelStatus(_event, _payload) { const downloader = new ModelDownloader(appDataDir); const status = downloader.getProgress(); + const isLoaded = Boolean(aiService.agent?.embeddingService?.embedder?.isLoaded); return new AIQueryResponse(true, { downloaded: downloader.isModelDownloaded(), isDownloading: status.isDownloading, - progress: status.progress + progress: status.progress, + isLoaded }); } catch (error) { return new AIQueryResponse(false, null, error.message); @@ -654,11 +656,13 @@ async function handleGetGraphModelStatus(_event, _payload) { const GraphModelDownloader = require('../../ai/graph/GraphModelDownloader'); const downloader = new GraphModelDownloader(appDataDir); const status = downloader.getStatus(); + const isLoaded = Boolean(aiService.agent?.graphWorker?.adapter?.isLoaded); return new AIQueryResponse(true, { downloaded: status.downloaded, isDownloading: status.isDownloading, - progress: status.progress + progress: status.progress, + isLoaded }); } catch (error) { return new AIQueryResponse(false, null, error.message); @@ -697,15 +701,22 @@ async function handleBuildGraph(_event, _payload) { if (workspaceFiles.length === 0 && workspaceRoot && fs.existsSync(workspaceRoot)) { function scanMarkdownFiles(dir) { let results = []; + const IGNORE_DIR_NAMES = new Set([ + 'node_modules', 'dist', 'build', 'out', 'assets', 'chunks', 'bundle', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'fixtures', 'mocks', + 'cypress', 'playwright', 'e2e' + ]); try { const list = fs.readdirSync(dir); for (const file of list) { - if (file.startsWith('.') || file === 'node_modules' || file === 'dist' || file === 'build') continue; + const lower = file.toLowerCase(); + if (file.startsWith('.') || IGNORE_DIR_NAMES.has(lower) || /.*[-_.]dist.*/i.test(lower) || /.*[-_.]build.*/i.test(lower)) continue; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat && stat.isDirectory()) { results = results.concat(scanMarkdownFiles(fullPath)); } else if (file.endsWith('.md')) { + if (/\.(test|spec)\.md$/i.test(file)) continue; results.push(fullPath); } } diff --git a/electron/ai/workerProcess.cjs b/electron/ai/workerProcess.cjs index 71bdb526..cf13e0f3 100644 --- a/electron/ai/workerProcess.cjs +++ b/electron/ai/workerProcess.cjs @@ -7,15 +7,22 @@ const fs = require('fs'); function scanMarkdownFiles(dir) { let results = []; + const IGNORE_DIR_NAMES = new Set([ + 'node_modules', 'dist', 'build', 'out', 'assets', 'chunks', 'bundle', 'static', + 'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'fixtures', 'mocks', + 'cypress', 'playwright', 'e2e' + ]); try { const list = fs.readdirSync(dir); for (const file of list) { - if (file.startsWith('.') || file === 'node_modules') continue; + const lower = file.toLowerCase(); + if (file.startsWith('.') || IGNORE_DIR_NAMES.has(lower) || /.*[-_.]dist.*/i.test(lower) || /.*[-_.]build.*/i.test(lower)) continue; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat && stat.isDirectory()) { results = results.concat(scanMarkdownFiles(fullPath)); } else if (file.endsWith('.md')) { + if (/\.(test|spec)\.md$/i.test(file)) continue; results.push(fullPath); } } @@ -28,6 +35,7 @@ let indexWorker = null; let queue = null; let localEmbedder = null; +let mockAgent = null; let graphDb = null; let graphQueue = null; let graphWorker = null; @@ -66,7 +74,7 @@ if (process.parentPort) { graphQueue = new GraphQueue(graphDb); const AIConfig = require('../../ai/core/AIConfig'); const aiConfig = new AIConfig(appDataDir); - const mockAgent = { appDataDir, workspaceRoot, config: aiConfig }; + mockAgent = { appDataDir, workspaceRoot, config: aiConfig }; graphService = new GraphService(mockAgent, graphDb); graphWorker = new GraphWorker(graphDb, graphQueue, graphService); @@ -216,9 +224,10 @@ if (process.parentPort) { } } else if (type === 'rebuildGraph') { let { workspaceFiles } = payload; + const workspaceRoot = graphDb?.workspaceRoot || mockAgent?.workspaceRoot; if (!Array.isArray(workspaceFiles) || workspaceFiles.length === 0) { - if (graphDb && graphDb.workspaceRoot) { - workspaceFiles = scanMarkdownFiles(graphDb.workspaceRoot); + if (workspaceRoot) { + workspaceFiles = scanMarkdownFiles(workspaceRoot); } else { workspaceFiles = []; } @@ -226,11 +235,45 @@ if (process.parentPort) { if (graphDb) { graphDb.clear(); } + + // Run comprehensive GraphBuilder to index attached repos, metadata, and diagrams + if (workspaceRoot && graphDb && graphService) { + try { + const GraphBuilder = require('../../ai/graph/GraphBuilder'); + const builder = new GraphBuilder(mockAgent, graphDb, graphService); + await builder.rebuild(); + } catch (bErr) { + console.error('[Worker Process] GraphBuilder rebuild error:', bErr); + } + } + if (graphQueue) { graphQueue.clear(); for (const file of workspaceFiles) { graphQueue.enqueue(file); } + + // Enqueue code files from attached repositories + if (workspaceRoot) { + try { + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + if (fs.existsSync(metaPath)) { + const metaObj = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (Array.isArray(metaObj.attachedRepos)) { + const { RepositoryScanner } = require('../../ai/graph/sources/code'); + const scanner = new RepositoryScanner(); + for (const r of metaObj.attachedRepos) { + if (r.path && fs.existsSync(r.path)) { + const codeFiles = scanner.scan(r.path); + for (const cf of codeFiles) { + graphQueue.enqueue(cf); + } + } + } + } + } + } catch { /* ignore */ } + } } if (graphWorker) { graphWorker.resume(); diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index acf55aea..f8cb425f 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -278,6 +278,7 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { checked: Boolean(context?.autosaveEnabled), click: () => sendMenuAction(win, "toggle-autosave") }, + { type: "separator" }, { label: "Export PDF", accelerator: "CmdOrCtrl+Shift+E", @@ -322,6 +323,12 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { click: () => sendMenuAction(win, "back-to-notes") }, { type: "separator" }, + { + label: "Settings\u2026", + accelerator: "CmdOrCtrl+,", + click: () => sendMenuAction(win, "open-settings") + }, + { type: "separator" }, { label: "Restart Notely", click: () => sendMenuAction(win, "restart-app") @@ -358,11 +365,6 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { submenu: openRecentSubmenu }, { type: "separator" }, - { - label: "Reload Workspace from Disk", - accelerator: "CmdOrCtrl+Alt+R", - click: () => sendMenuAction(win, "reload-workspace") - }, { label: "Export Note Package", click: () => sendMenuAction(win, "open-export-package") @@ -373,12 +375,18 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { }, { type: "separator" }, { - label: `Move ${currentFolderLabel} to Removed`, + label: `Move ${currentFolderLabel} to Trash`, accelerator: "CmdOrCtrl+Shift+Delete", enabled: canRemoveFolder, click: () => sendMenuAction(win, "remove-folder") }, { type: "separator" }, + { + label: "Settings\u2026", + accelerator: "CmdOrCtrl+,", + click: () => sendMenuAction(win, "open-settings") + }, + { type: "separator" }, { label: "Restart Notely", click: () => sendMenuAction(win, "restart-app") @@ -436,10 +444,6 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { const viewSubmenu = screen === "document" ? [ - { - label: "Open Command Palette", - click: () => sendMenuAction(win, "open-command-palette") - }, { type: "separator" }, { label: "Theme", @@ -606,7 +610,7 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { }, { type: "separator" }, { - label: "Toggle Full Screen (Focus Mode)", + label: "Toggle Full Screen", accelerator: "F11", click: () => sendMenuAction(win, "toggle-focus-mode") }, @@ -648,10 +652,6 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { : []) ] : [ - { - label: "Open Command Palette", - click: () => sendMenuAction(win, "open-command-palette") - }, { type: "separator" }, { label: "Theme", @@ -718,24 +718,24 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { }, { type: "separator" }, { - label: "Dashboard View", + label: "Notes View", submenu: [ { - label: "Tile Notes", + label: "Tile", accelerator: "CmdOrCtrl+1", type: "checkbox", checked: viewMode === "tile", click: () => sendMenuAction(win, "view-tile") }, { - label: "Table Notes", + label: "Table", accelerator: "CmdOrCtrl+2", type: "checkbox", checked: viewMode === "table", click: () => sendMenuAction(win, "view-table") }, { - label: "Tree Notes", + label: "Tree", accelerator: "CmdOrCtrl+3", type: "checkbox", checked: viewMode === "tree", @@ -821,6 +821,10 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { label: "Calendar", click: () => sendMenuAction(win, "open-calendar") }, + { + label: "Knowledge Graph", + click: () => sendMenuAction(win, "open-knowledge-graph") + }, { type: "separator" }, { label: "Workspace Information", @@ -860,15 +864,15 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { accelerator: "CmdOrCtrl+Alt+G", click: () => sendMenuAction(win, "open-attached-repos") }, + { + label: "Trash", + click: () => sendMenuAction(win, "open-trash") + }, { label: "Workspace Activity", accelerator: "CmdOrCtrl+Shift+A", click: () => sendMenuAction(win, "open-workspace-activity") }, - { - label: "Trash / Removed Items", - click: () => sendMenuAction(win, "open-trash") - }, { label: "Reload Workspace", accelerator: "CmdOrCtrl+Alt+R", @@ -893,116 +897,93 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { ] }, { - label: "Version Control", + label: "Tools", submenu: [ { - label: "Open Version Control", - accelerator: "CmdOrCtrl+Shift+G", - click: () => sendMenuAction(win, "open-git-version-control") - }, - { type: "separator" }, - { - label: "Commit\u2026", - accelerator: "CmdOrCtrl+Alt+K", - click: () => sendMenuAction(win, "git-commit") - }, - { - label: "History", - accelerator: "CmdOrCtrl+Shift+H", - enabled: screen === "document", - click: () => sendMenuAction(win, "git-history") - }, - { - label: "Diff Current Note", - enabled: screen === "document", - click: () => sendMenuAction(win, "git-diff-current") - }, - { - label: "Compare Versions", - click: () => sendMenuAction(win, "git-compare") - }, - { type: "separator" }, - { - label: "Push", - accelerator: "CmdOrCtrl+Shift+U", - click: () => sendMenuAction(win, "git-push") - }, - { - label: "Pull", - click: () => sendMenuAction(win, "git-pull") - }, - { - label: "Fetch", - click: () => sendMenuAction(win, "git-fetch") - }, - { - label: "Sync (Pull then Push)", - click: () => sendMenuAction(win, "git-sync") - }, - { type: "separator" }, - { - label: "Ignore App Data in Git", - type: "checkbox", - checked: Boolean(context?.autoIgnoreMetadataInGit !== false), - click: () => sendMenuAction(win, "toggle-auto-ignore-git-metadata") - }, - - ] - }, - { - label: "P2P", - submenu: [ - { - label: "P2P Status", - accelerator: "CmdOrCtrl+Shift+P", - click: () => sendMenuAction(win, "open-p2p-status") - }, - { type: "separator" }, - { - label: "Run Sync Self-Test", - click: () => sendMenuAction(win, "run-p2p-sync-self-test") - }, - { - label: "Conflict Center", - click: () => sendMenuAction(win, "open-p2p-conflicts") - }, - { type: "separator" }, - { - label: "Rotate Workspace Keys", - click: () => sendMenuAction(win, "rotate-p2p-workspace-keys") + label: "Revisions & Sync", + submenu: [ + { + label: "Open Revisions & Sync", + accelerator: "CmdOrCtrl+Shift+G", + click: () => sendMenuAction(win, "open-git-version-control") + }, + { type: "separator" }, + { + label: "Save Milestone…", + accelerator: "CmdOrCtrl+Alt+K", + click: () => sendMenuAction(win, "git-commit") + }, + { + label: "Note History Drawer", + accelerator: "CmdOrCtrl+Shift+H", + enabled: screen === "document", + click: () => sendMenuAction(win, "git-history") + }, + { + label: "Sync with Cloud", + accelerator: "CmdOrCtrl+Shift+U", + click: () => sendMenuAction(win, "git-sync") + }, + { type: "separator" }, + { + label: "Ignore App Data in Git", + type: "checkbox", + checked: Boolean(context?.autoIgnoreMetadataInGit !== false), + click: () => sendMenuAction(win, "toggle-auto-ignore-git-metadata") + } + ] }, { type: "separator" }, { - label: "How Sync Works", - click: () => sendMenuAction(win, "open-p2p-sync-help") - } - ] - }, - { - label: "AI", - submenu: [ - { - label: "AI Settings", - accelerator: "CmdOrCtrl+Shift+,", - click: () => sendMenuAction(win, "open-ai-settings") - }, - { - label: "MCP Server Settings", - click: () => sendMenuAction(win, "open-mcp-settings") + label: "P2P Sync", + submenu: [ + { + label: "Run Sync Self-Test", + click: () => sendMenuAction(win, "run-p2p-sync-self-test") + }, + { + label: "Conflict Center", + click: () => sendMenuAction(win, "open-p2p-conflicts") + }, + { type: "separator" }, + { + label: "Rotate Workspace Keys", + click: () => sendMenuAction(win, "rotate-p2p-workspace-keys") + }, + { type: "separator" }, + { + label: "How Sync Works", + click: () => sendMenuAction(win, "open-p2p-sync-help") + } + ] }, { type: "separator" }, { - label: "Knowledge Graph", - click: () => sendMenuAction(win, "open-knowledge-graph") - }, - { - label: "Embeddings", - click: () => sendMenuAction(win, "open-embeddings-page") + label: "AI & Knowledge", + submenu: [ + { + label: "Knowledge Graph", + click: () => sendMenuAction(win, "open-knowledge-graph") + }, + { + label: "Embeddings", + click: () => sendMenuAction(win, "open-embeddings-page") + } + ] }, - { type: "separator" }, { - label: "MCP Diagnostics & Health", - click: () => sendMenuAction(win, "open-health-page") + label: "MCP", + submenu: [ + { + label: "MCP Tools & Capabilities", + click: () => sendMenuAction(win, "open-mcp-tools") + }, + { type: "separator" }, + { + label: "Diagnostics", + click: () => sendMenuAction(win, "open-diagnostics") + } + ] } ] }, @@ -1033,10 +1014,6 @@ function buildAppMenuTemplate(win, context = {}, deps = {}) { label: "System & Application Logs", click: () => sendMenuAction(win, "open-app-logs") }, - { - label: "MCP Tools & Capabilities", - click: () => sendMenuAction(win, "open-mcp-tools") - }, { type: "separator" }, { label: "Check for Updates", diff --git a/electron/lib/documents/documentIpc.cjs b/electron/lib/documents/documentIpc.cjs index c205b867..ea9d0003 100644 --- a/electron/lib/documents/documentIpc.cjs +++ b/electron/lib/documents/documentIpc.cjs @@ -538,18 +538,39 @@ function registerDocumentIpcHandlers(ipcMain, deps) { return { success: true, count: changedCount }; }); - registerTrustedHandler("documents:open-in-editor", async (_event, filePath) => { + registerTrustedHandler("documents:open-in-editor", async (_event, payload) => { + const rawPath = typeof payload === "string" ? payload : payload?.filePath; + const lineNumber = typeof payload === "object" ? payload?.line || payload?.lineNumber : null; const notesRoot = getNotesRoot(); - const resolved = path.resolve(filePath || ""); - if (!filePathWithin(notesRoot, resolved) || path.extname(resolved).toLowerCase() !== ".md") { - throw new Error("Invalid document path."); + const resolved = path.resolve(rawPath || ""); + + let isAllowed = filePathWithin(notesRoot, resolved); + if (!isAllowed) { + const metaPath = path.join(notesRoot, ".notes-app", "metadata.json"); + if (fs.existsSync(metaPath)) { + try { + const metaObj = JSON.parse(fs.readFileSync(metaPath, "utf8")); + const repos = Array.isArray(metaObj.attachedRepos) ? metaObj.attachedRepos : []; + for (const repo of repos) { + if (repo.path && filePathWithin(repo.path, resolved)) { + isAllowed = true; + break; + } + } + } catch { /* ignore */ } + } + } + + if (!isAllowed) { + throw new Error("Invalid file path or target outside workspace and attached repositories."); } if (!fs.existsSync(resolved)) { throw new Error("Document file does not exist."); } try { - const vscodeUri = `vscode://file/${resolved.replace(/\\/g, "/")}`; + const lineSuffix = lineNumber ? `:${lineNumber}` : ""; + const vscodeUri = `vscode://file/${resolved.replace(/\\/g, "/")}${lineSuffix}`; await shell.openExternal(encodeURI(vscodeUri)); return { openedWith: "vscode" }; } catch { diff --git a/electron/lib/git/gitService.cjs b/electron/lib/git/gitService.cjs index f3921f6f..fab05d4e 100644 --- a/electron/lib/git/gitService.cjs +++ b/electron/lib/git/gitService.cjs @@ -728,7 +728,7 @@ async function push(workspacePath, options = {}) { const repoRoot = await findRepoRoot(workspacePath); if (!repoRoot) return fail("Not a git repository."); - const { remote = "origin", branch, auth } = options; + const { remote = "origin", branch, auth, setUpstream = true } = options; const g = git(repoRoot); // For PAT auth, rewrite the remote URL temporarily @@ -743,7 +743,14 @@ async function push(workspacePath, options = {}) { } } - const pushArgs = branch ? [remote, branch] : [remote]; + const status = await g.status().catch(() => ({})); + const currentBranch = branch || status.current || "main"; + + // Use -u to establish upstream tracking so future git pull/push know which branch to track + const pushArgs = setUpstream || !status.tracking + ? ["-u", remote, currentBranch] + : (branch ? [remote, branch] : [remote]); + const result = await g.push(pushArgs); // Restore original URL (remove embedded credentials) @@ -781,8 +788,27 @@ async function pull(workspacePath, options = {}) { } } - const pullArgs = branch ? [remote, branch] : [remote]; - const result = await g.pull(...pullArgs); + const status = await g.status().catch(() => ({})); + const currentBranch = branch || status.current || "main"; + + let result; + try { + result = await g.pull(remote, currentBranch); + } catch (pullErr) { + const errMsg = String(pullErr?.message || ""); + // If remote branch doesn't exist yet or tracking info is not configured, don't fail sync + if ( + errMsg.includes("There is no tracking information") || + errMsg.includes("couldn't find remote ref") || + errMsg.includes("no such ref was fetched") || + errMsg.includes("fatal: couldn't find remote ref") || + errMsg.includes("branch does not exist") + ) { + result = { files: [], summary: { insertions: 0, deletions: 0 } }; + } else { + throw pullErr; + } + } if (remoteUrl) { try { diff --git a/electron/lib/ipc/coreIpc.cjs b/electron/lib/ipc/coreIpc.cjs index d44c3d61..626bd415 100644 --- a/electron/lib/ipc/coreIpc.cjs +++ b/electron/lib/ipc/coreIpc.cjs @@ -752,7 +752,36 @@ function registerCoreIpcHandlers(ipcMain, deps) { registerTrustedHandler("workspace-metadata:get-attached-repos", () => { if (typeof getWorkspaceMetadataStore === "function") { const store = getWorkspaceMetadataStore(); - if (store) return store.getAttachedRepos(); + if (store) { + const repos = store.getAttachedRepos() || []; + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + try { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + gdb.exec("PRAGMA busy_timeout = 5000;"); + try { + for (const r of repos) { + if (r.status === "unindexed" || !r.symbolCount) { + const row = gdb.prepare("SELECT COUNT(*) as count FROM entities WHERE properties LIKE ?").get(`%"repoId":"${r.id}"%`); + if (row && row.count > 0) { + r.status = "indexed"; + r.symbolCount = row.count; + r.lastScannedAt = r.lastScannedAt || new Date().toISOString(); + store.updateAttachedRepo(r.id, r); + } + } + } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } catch { /* ignore sync error */ } + } + return repos; + } } return []; }); @@ -774,12 +803,124 @@ function registerCoreIpcHandlers(ipcMain, deps) { } } catch { /* ignore git info failure */ } } + + // Initialize repo record and upsert repo entity immediately + repoData.status = 'indexing'; + repoData.symbolCount = 1; + repoData.lastScannedAt = new Date().toISOString(); + + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + try { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + const repoId = `ent-code-${String(repoData.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + gdb.prepare(` + INSERT INTO entities (id, name, canonical_name, type, properties, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + canonical_name = excluded.canonical_name, + type = excluded.type, + properties = excluded.properties, + updated_at = excluded.updated_at + `).run(repoId, repoData.name, repoData.name, 'Repo', JSON.stringify({ repoId: repoData.id, path: repoData.path }), new Date().toISOString()); + gdb.close(); + } catch { /* ignore initial repo node insert */ } + } + } + const added = store.addAttachedRepo(repoData); const allRepos = store.getAttachedRepos(); for (const win of BrowserWindow.getAllWindows()) { if (!win || win.isDestroyed()) continue; win.webContents.send("workspace-metadata:attached-repos-changed", allRepos); } + + // Run detailed AST code extraction in non-blocking background queue + if (repoData.path && fs.existsSync(repoData.path)) { + setImmediate(async () => { + try { + const { CodeKnowledgeSource, RepositoryScanner } = require("../../../ai/graph/sources/code"); + const codeSource = new CodeKnowledgeSource([repoData]); + const scanner = new RepositoryScanner(); + const files = scanner.scan(repoData.path); + + let symbolCount = 1; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + gdb.exec("PRAGMA busy_timeout = 5000;"); + try { + // Process files in non-blocking chunks of 8 + const CHUNK_SIZE = 8; + for (let i = 0; i < files.length && i < 200; i += CHUNK_SIZE) { + const chunk = files.slice(i, i + CHUNK_SIZE); + gdb.exec("BEGIN"); + for (const filePath of chunk) { + try { + const fileEntities = await codeSource.extractEntities(filePath); + const fileRelationships = await codeSource.extractRelationships(filePath); + + for (const ent of fileEntities) { + const entId = `ent-code-${String(ent.name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + gdb.prepare(` + INSERT INTO entities (id, name, canonical_name, type, properties, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + canonical_name = excluded.canonical_name, + type = excluded.type, + properties = excluded.properties, + updated_at = excluded.updated_at + `).run(entId, ent.name, ent.canonical_name || ent.name, ent.type || 'CodeModule', JSON.stringify(ent.properties || {}), new Date().toISOString()); + symbolCount++; + } + + for (const rel of fileRelationships) { + const srcId = `ent-code-${String(rel.source_name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + const tgtId = `ent-code-${String(rel.target_name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + if (srcId !== tgtId) { + gdb.prepare(` + INSERT OR IGNORE INTO relationships (source_id, target_id, type, weight, confidence, metadata) + VALUES (?, ?, ?, ?, ?, ?) + `).run(srcId, tgtId, rel.type, rel.weight || 1.0, rel.confidence || 0.95, JSON.stringify(rel.metadata || {})); + } + } + } catch { /* ignore individual file error */ } + } + gdb.exec("COMMIT"); + // Yield to event loop between chunks + await new Promise(resolve => setImmediate(resolve)); + } + } catch { + try { gdb.exec("ROLLBACK"); } catch { /* ignore */ } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } + + store.updateAttachedRepo(repoData.id, { + status: 'indexed', + symbolCount, + lastScannedAt: new Date().toISOString() + }); + const updatedRepos = store.getAttachedRepos(); + for (const win of BrowserWindow.getAllWindows()) { + if (!win || win.isDestroyed()) continue; + win.webContents.send("workspace-metadata:attached-repos-changed", updatedRepos); + } + } catch (bgErr) { + console.warn("[Background Repo Scanner] Error:", bgErr.message); + store.updateAttachedRepo(repoData.id, { status: 'indexed' }); + } + }); + } return { success: true, repo: added, all: allRepos }; } } @@ -791,7 +932,36 @@ function registerCoreIpcHandlers(ipcMain, deps) { if (typeof getWorkspaceMetadataStore === "function") { const store = getWorkspaceMetadataStore(); if (store) { + const repo = (store.getAttachedRepos() || []).find((r) => r.id === repoId || r.path === repoId); const removed = store.removeAttachedRepo(repoId); + + // Purge repo entities from ai-graph.db if workspace db exists + try { + const notesRoot = typeof getNotesRoot === "function" ? getNotesRoot() : null; + if (notesRoot) { + const dbPath = path.join(notesRoot, ".notes-app", "ai-graph.db"); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require("node:sqlite"); + const gdb = new DatabaseSync(dbPath); + gdb.exec("PRAGMA busy_timeout = 5000;"); + try { + gdb.exec("BEGIN"); + if (repo && repo.id) { + gdb.prepare("DELETE FROM entities WHERE properties LIKE ?").run(`%"repoId":"${repo.id}"%`); + } + if (repo && repo.path) { + gdb.prepare("DELETE FROM entities WHERE properties LIKE ?").run(`%"absolutePath":"${repo.path.replace(/\\/g, '\\\\')}%`); + } + gdb.exec("COMMIT"); + } catch { + try { gdb.exec("ROLLBACK"); } catch { /* ignore */ } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } + } catch { /* ignore graph purge errors */ } + const allRepos = store.getAttachedRepos(); for (const win of BrowserWindow.getAllWindows()) { if (!win || win.isDestroyed()) continue; diff --git a/electron/lib/media/imageMedia.cjs b/electron/lib/media/imageMedia.cjs index 820e22b2..30116e04 100644 --- a/electron/lib/media/imageMedia.cjs +++ b/electron/lib/media/imageMedia.cjs @@ -367,20 +367,24 @@ function collectImageUsage(basePath) { const markdownFiles = walkFiles(scopeRoot, { excludeDirs: Array.from(WALK_EXCLUDE_DIRS) }) .filter((item) => path.extname(item).toLowerCase() === ".md"); const markdownMediaPattern = /(?:!\[[^\]]*\]|\[[^\]]*\])\((<[^>]+>|[^)]+)\)/g; + const wikilinkPattern = /(?:!\[\[|\[\[)([^\]|]+)(?:\|[^\]]+)?\]\]/g; + const htmlMediaPattern = /<(?:img|audio|video|source)\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi; const usageByAssetPath = {}; for (const markdownFile of markdownFiles) { const content = fs.readFileSync(markdownFile, "utf8"); const seenInDocument = new Set(); - let match; - while ((match = markdownMediaPattern.exec(content))) { - const rawPath = String(match[1] || "").trim(); - const assetPath = rawPath.startsWith("<") && rawPath.endsWith(">") - ? rawPath.slice(1, -1) - : rawPath; - const resolvedAssetPath = resolveImageAssetPath(markdownFile, assetPath); - if (!resolvedAssetPath) continue; + const processCandidatePath = (rawCandidate) => { + let rawPath = String(rawCandidate || "").trim(); + if (!rawPath || /^(https?:|data:|blob:|mailto:|#)/i.test(rawPath)) return; + if (rawPath.startsWith("<") && rawPath.endsWith(">")) { + rawPath = rawPath.slice(1, -1); + } + rawPath = rawPath.replace(/\s+["'][^"']*["']\s*$/, "").split("?")[0].split("#")[0].trim(); + + const resolvedAssetPath = resolveImageAssetPath(markdownFile, rawPath); + if (!resolvedAssetPath) return; const resolved = path.resolve(resolvedAssetPath); const baseDir = path.dirname(resolvedBasePath); @@ -407,20 +411,48 @@ function collectImageUsage(basePath) { relativeAssetPath = normalizeToPosix(path.relative(baseDir, resolved)); } - if (seenInDocument.has(relativeAssetPath)) continue; - seenInDocument.add(relativeAssetPath); - - const entry = usageByAssetPath[relativeAssetPath] || { - referenceCount: 0, - documents: [], - }; - entry.referenceCount += 1; - entry.documents.push({ + if (seenInDocument.has(resolved.toLowerCase())) return; + seenInDocument.add(resolved.toLowerCase()); + + const aliases = new Set([ + relativeAssetPath, + `./images/${fileName}`, + `images/${fileName}`, + `/images/${fileName}`, + `media/images/${fileName}`, + `/media/images/${fileName}`, + `./media/images/${fileName}`, + `media/docs/${fileName}`, + `/media/docs/${fileName}`, + fileName, + ]); + + const docRef = { filePath: markdownFile, fileName: path.basename(markdownFile), title: path.basename(markdownFile, ".md"), - }); - usageByAssetPath[relativeAssetPath] = entry; + }; + + for (const alias of aliases) { + const entry = usageByAssetPath[alias] || { + referenceCount: 0, + documents: [], + }; + entry.referenceCount += 1; + entry.documents.push(docRef); + usageByAssetPath[alias] = entry; + } + }; + + let match; + while ((match = markdownMediaPattern.exec(content))) { + processCandidatePath(match[1]); + } + while ((match = wikilinkPattern.exec(content))) { + processCandidatePath(match[1]); + } + while ((match = htmlMediaPattern.exec(content))) { + processCandidatePath(match[1]); } } @@ -742,33 +774,56 @@ registerTrustedHandler("images:save", (_event, payload) => { const finalExt = ext || ".bin"; const imageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp", ".ico", ".gif", ".svg"]); - const isImage = imageExtensions.has(finalExt); - - const subFolder = isImage ? "images" : "docs"; - const mediaDir = path.join(getNotesRoot(), "media", subFolder); - ensureDir(mediaDir); + const audioExtensions = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"]); + const videoExtensions = new Set([".mp4", ".mov", ".mkv", ".avi"]); - let finalName = `${baseName}${finalExt}`; - let counter = 1; + const isImage = imageExtensions.has(finalExt); + const isAudio = audioExtensions.has(finalExt); + const isVideo = videoExtensions.has(finalExt); + + let relPath = ""; + let mediaFilePath = ""; + + if (isAudio) { + const bundleDirName = `${baseName}_${Date.now().toString(36)}`; + const bundleDir = path.join(getNotesRoot(), "media", "audio", bundleDirName); + ensureDir(bundleDir); + mediaFilePath = path.join(bundleDir, `${baseName}${finalExt}`); + relPath = `media/audio/${bundleDirName}/${baseName}${finalExt}`; + } else if (isVideo) { + const bundleDirName = `${baseName}_${Date.now().toString(36)}`; + const bundleDir = path.join(getNotesRoot(), "media", "video", bundleDirName); + ensureDir(bundleDir); + mediaFilePath = path.join(bundleDir, `${baseName}${finalExt}`); + relPath = `media/video/${bundleDirName}/${baseName}${finalExt}`; + } else { + const subFolder = isImage ? "images" : "docs"; + const mediaDir = path.join(getNotesRoot(), "media", subFolder); + ensureDir(mediaDir); + + let finalName = `${baseName}${finalExt}`; + let counter = 1; + while (fs.existsSync(path.join(mediaDir, finalName))) { + finalName = `${baseName}-${counter}${finalExt}`; + counter++; + } - while (fs.existsSync(path.join(mediaDir, finalName))) { - finalName = `${baseName}-${counter}${finalExt}`; - counter++; + mediaFilePath = path.join(mediaDir, finalName); + relPath = `media/${subFolder}/${finalName}`; } - const mediaFilePath = path.join(mediaDir, finalName); const buffer = Buffer.from(base64Data.split(",")[1], "base64"); if (!buffer.length) { throw new Error("File data is empty."); } fs.writeFileSync(mediaFilePath, buffer); - + if (isImage) { ensureImageThumbnail(mediaFilePath); } emitImageSyncFromDisk(mediaFilePath, { op: "create" }); - return `/media/${subFolder}/${finalName}`; + return `/${relPath.replace(/\\/g, "/")}`; }); registerTrustedHandler("video:save", (_event, payload) => { @@ -781,10 +836,14 @@ registerTrustedHandler("video:save", (_event, payload) => { } const safeFileName = path.basename(fileName).replace(/[<>:"/\\|?*]+/g, "-"); - const recordingsDir = path.join(getNotesRoot(), "media", "recordings"); - ensureDir(recordingsDir); + const ext = path.extname(safeFileName) || ".webm"; + const baseName = path.basename(safeFileName, ext) || "recording"; + + const bundleDirName = `${baseName}_${Date.now().toString(36)}`; + const bundleDir = path.join(getNotesRoot(), "media", "video", bundleDirName); + ensureDir(bundleDir); - const targetPath = path.join(recordingsDir, safeFileName); + const targetPath = path.join(bundleDir, `${baseName}${ext}`); if (!filePathWithin(getNotesRoot(), targetPath)) { throw new Error("Invalid recording path."); } @@ -794,25 +853,45 @@ registerTrustedHandler("video:save", (_event, payload) => { throw new Error("Video data is empty."); } fs.writeFileSync(targetPath, buffer); - return `media/recordings/${safeFileName}`; + return `media/video/${bundleDirName}/${baseName}${ext}`; }); registerTrustedHandler("audio:save", (_event, payload) => { const { fileName, base64Data, transcript } = payload || {}; - const safeFileName = path.basename(fileName || "transcript.json").replace(/[<>:"/\\|?*]+/g, "-"); - const audioDir = path.join(getNotesRoot(), "media", "audio"); - ensureDir(audioDir); + const rawFileName = String(fileName || "recording.webm"); + const safeFileName = path.basename(rawFileName).replace(/[<>:"/\\|?*]+/g, "-"); // Handle standalone companion transcript saving if (transcript && !base64Data) { - const transcriptFileName = safeFileName.endsWith(".json") ? safeFileName : safeFileName.replace(/\.[^.]+$/, "") + ".json"; - const transcriptPath = path.join(audioDir, transcriptFileName); + let transcriptPath = ""; + let transcriptRelPath = ""; + + if (transcript.sourceMedia && typeof transcript.sourceMedia === "string") { + const sourceNorm = transcript.sourceMedia.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + const parentDir = path.dirname(sourceNorm); + const targetDir = path.join(getNotesRoot(), parentDir); + ensureDir(targetDir); + transcriptPath = path.join(targetDir, "transcript.json"); + transcriptRelPath = `${parentDir}/transcript.json`.replace(/\\/g, "/"); + } else if (rawFileName.includes("/") || rawFileName.includes("\\")) { + const relNorm = rawFileName.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + transcriptPath = path.join(getNotesRoot(), relNorm); + ensureDir(path.dirname(transcriptPath)); + transcriptRelPath = relNorm; + } else { + const baseName = safeFileName.replace(/\.[^.]+$/, ""); + const bundleDir = path.join(getNotesRoot(), "media", "audio", baseName); + ensureDir(bundleDir); + transcriptPath = path.join(bundleDir, "transcript.json"); + transcriptRelPath = `media/audio/${baseName}/transcript.json`; + } + const content = typeof transcript === "string" ? transcript : JSON.stringify(transcript, null, 2); fs.writeFileSync(transcriptPath, content, "utf8"); return { audioPath: null, - transcriptPath: `media/audio/${transcriptFileName}`, - fileName: transcriptFileName, + transcriptPath: transcriptRelPath, + fileName: path.basename(transcriptPath), }; } @@ -820,7 +899,13 @@ registerTrustedHandler("audio:save", (_event, payload) => { throw new Error("Invalid audio payload."); } - const targetPath = path.join(audioDir, safeFileName); + const ext = path.extname(safeFileName) || ".webm"; + const baseName = path.basename(safeFileName, ext) || "recording"; + const bundleDirName = `${baseName}_${Date.now().toString(36)}`; + const bundleDir = path.join(getNotesRoot(), "media", "audio", bundleDirName); + ensureDir(bundleDir); + + const targetPath = path.join(bundleDir, `${baseName}${ext}`); if (!filePathWithin(getNotesRoot(), targetPath)) { throw new Error("Invalid audio path."); } @@ -833,17 +918,16 @@ registerTrustedHandler("audio:save", (_event, payload) => { let transcriptRelPath = null; if (transcript) { - const transcriptFileName = safeFileName.replace(/\.[^.]+$/, "") + ".json"; - const transcriptPath = path.join(audioDir, transcriptFileName); + const transcriptPath = path.join(bundleDir, "transcript.json"); const content = typeof transcript === "string" ? transcript : JSON.stringify(transcript, null, 2); fs.writeFileSync(transcriptPath, content, "utf8"); - transcriptRelPath = `media/audio/${transcriptFileName}`; + transcriptRelPath = `media/audio/${bundleDirName}/transcript.json`; } return { - audioPath: `media/audio/${safeFileName}`, + audioPath: `media/audio/${bundleDirName}/${baseName}${ext}`, transcriptPath: transcriptRelPath, - fileName: safeFileName, + fileName: `${baseName}${ext}`, }; }); diff --git a/electron/lib/tasks/TaskDatabase.cjs b/electron/lib/tasks/TaskDatabase.cjs index a0b6e50a..32f21072 100644 --- a/electron/lib/tasks/TaskDatabase.cjs +++ b/electron/lib/tasks/TaskDatabase.cjs @@ -203,11 +203,11 @@ class TaskDatabase { if (match) { matchedTaskIds.add(match.id); matchedParsedIndices.add(t.idx); - if (match.status !== t.status || match.source_line !== t.line) { + if (match.status !== t.status || match.source_line !== t.line || match.priority !== t.priority || match.due_date !== t.due_date) { const completedAt = t.status === 'done' ? now : (t.status === 'open' ? null : match.completed_at); this.db.prepare( - 'UPDATE tasks SET status = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' - ).run(t.status, t.line, now, completedAt, match.id); + 'UPDATE tasks SET status = ?, source_line = ?, priority = ?, due_date = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(t.status, t.line, t.priority ?? 0, t.due_date ?? null, now, completedAt, match.id); updated++; } } @@ -224,8 +224,8 @@ class TaskDatabase { const newHash = this._getUniqueSourceHash(filePath, t.title, t.occ, match.id); const completedAt = t.status === 'done' ? now : null; this.db.prepare( - 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' - ).run(t.title, t.status, newHash, t.line, now, completedAt, match.id); + 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, priority = ?, due_date = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(t.title, t.status, newHash, t.line, t.priority ?? 0, t.due_date ?? null, now, completedAt, match.id); updated++; } } @@ -255,29 +255,30 @@ class TaskDatabase { matchedTaskIds.add(bestMatch.id); matchedParsedIndices.add(p.idx); const newHash = this._getUniqueSourceHash(filePath, p.title, p.occ, bestMatch.id); - const completedAt = p.status === 'done' ? now : null; + const completedAt = p.completed_at || (p.status === 'done' ? (bestMatch.completed_at || now) : null); this.db.prepare( - 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' - ).run(p.title, p.status, newHash, p.line, now, completedAt, bestMatch.id); + 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, priority = ?, due_date = ?, scheduled_start = ?, scheduled_end = ?, is_all_day = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(p.title, p.status, newHash, p.line, p.priority ?? 0, p.due_date ?? null, p.scheduled_start ?? null, p.scheduled_end ?? null, p.is_all_day ?? 1, now, completedAt, bestMatch.id); updated++; } else { // Insert new task safely checking if source_hash already exists const hash = this._getUniqueSourceHash(filePath, p.title, p.occ); - const existingHashMatch = this.db.prepare('SELECT id, user_managed FROM tasks WHERE source_hash = ?').get(hash); + const existingHashMatch = this.db.prepare('SELECT id, user_managed, completed_at FROM tasks WHERE source_hash = ?').get(hash); if (existingHashMatch && !existingHashMatch.user_managed) { matchedTaskIds.add(existingHashMatch.id); - const completedAt = p.status === 'done' ? now : null; + const completedAt = p.completed_at || (p.status === 'done' ? (existingHashMatch.completed_at || now) : null); this.db.prepare( - 'UPDATE tasks SET status = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' - ).run(p.status, p.line, now, completedAt, existingHashMatch.id); + 'UPDATE tasks SET status = ?, source_line = ?, priority = ?, due_date = ?, scheduled_start = ?, scheduled_end = ?, is_all_day = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(p.status, p.line, p.priority ?? 0, p.due_date ?? null, p.scheduled_start ?? null, p.scheduled_end ?? null, p.is_all_day ?? 1, now, completedAt, existingHashMatch.id); updated++; } else { + const completedAt = p.completed_at || (p.status === 'done' ? now : null); this.db.prepare(` - INSERT INTO tasks (id, title, status, source_path, source_line, source_hash, - user_managed, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) - `).run(randomId(), p.title, p.status, filePath, p.line, hash, now, now); + INSERT INTO tasks (id, title, status, priority, due_date, scheduled_start, scheduled_end, is_all_day, + source_path, source_line, source_hash, user_managed, created_at, updated_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?) + `).run(randomId(), p.title, p.status, p.priority ?? 0, p.due_date ?? null, p.scheduled_start ?? null, p.scheduled_end ?? null, p.is_all_day ?? 1, filePath, p.line, hash, now, now, completedAt); inserted++; } } @@ -306,28 +307,39 @@ class TaskDatabase { title: t.title, description: '', status: t.status, - priority: 0, + priority: t.priority ?? 0, source_path: filePath, source_line: t.line, source_hash: hash, user_managed: 0, - due_date: null, - scheduled_start: null, - scheduled_end: null, - is_all_day: 1, + due_date: t.due_date ?? null, + scheduled_start: t.scheduled_start ?? null, + scheduled_end: t.scheduled_end ?? null, + is_all_day: t.is_all_day ?? 1, reminder: null, person_tags: [], metadata: {}, created_at: now, updated_at: now, - completed_at: t.status === 'done' ? now : null, + completed_at: t.completed_at || (t.status === 'done' ? now : null), }); inserted++; - } else if (!existing.user_managed && existing.status !== t.status) { + } else if (!existing.user_managed && ( + existing.status !== t.status || + existing.priority !== t.priority || + existing.due_date !== t.due_date || + existing.scheduled_start !== t.scheduled_start || + existing.scheduled_end !== t.scheduled_end + )) { existing.status = t.status; + existing.priority = t.priority ?? 0; + existing.due_date = t.due_date ?? null; + existing.scheduled_start = t.scheduled_start ?? null; + existing.scheduled_end = t.scheduled_end ?? null; + existing.is_all_day = t.is_all_day ?? 1; existing.source_line = t.line; existing.updated_at = now; - existing.completed_at = t.status === 'done' ? now : null; + existing.completed_at = t.completed_at || (t.status === 'done' ? (existing.completed_at || now) : null); updated++; } else if (existing.source_line !== t.line) { existing.source_line = t.line; @@ -692,6 +704,85 @@ class TaskDatabase { // ── Calendar Events ─────────────────────────────────────────────────────── getCalendarTaskEvents(startDate, endDate) { + const toLocalDate = (val) => { + if (!val) return ''; + const d = new Date(val); + if (isNaN(d.getTime())) return ''; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + }; + + const todayStr = toLocalDate(new Date()); + const events = []; + + const processTask = (t) => { + const isAllDay = Boolean(t.isAllDay ?? t.is_all_day ?? 1); + + // 1. Scheduled event + if (t.scheduled_start) { + const schedDate = toLocalDate(t.scheduled_start); + if (!startDate || !endDate || (schedDate >= startDate && schedDate <= endDate)) { + events.push({ + id: `task-sched-${t.id}`, + taskId: t.id, + title: t.title, + start: t.scheduled_start, + end: t.scheduled_end || t.scheduled_start, + allDay: isAllDay, + type: 'task-scheduled', + _eventType: 'task-scheduled', + sourcePath: t.source_path, + priority: t.priority, + status: t.status, + }); + } + } + + // 2. Due event (or overdue) + if (t.due_date) { + const dueDate = t.due_date.slice(0, 10); + if (!startDate || !endDate || (dueDate >= startDate && dueDate <= endDate)) { + const isOverdue = t.status !== 'done' && dueDate < todayStr; + const eventType = isOverdue ? 'task-overdue' : 'task-due'; + events.push({ + id: `task-due-${t.id}`, + taskId: t.id, + title: t.title, + start: t.due_date, + end: t.due_date, + allDay: isAllDay, + type: eventType, + _eventType: eventType, + sourcePath: t.source_path, + priority: t.priority, + status: t.status, + }); + } + } + + // 3. Completed event + if (t.completed_at && t.status === 'done') { + const compDate = toLocalDate(t.completed_at); + if (!startDate || !endDate || (compDate >= startDate && compDate <= endDate)) { + events.push({ + id: `task-done-${t.id}`, + taskId: t.id, + title: `Completed: ${t.title}`, + start: t.completed_at, + end: t.completed_at, + allDay: false, + type: 'task-completed', + _eventType: 'task-completed', + sourcePath: t.source_path, + priority: t.priority, + status: t.status, + }); + } + } + }; + if (this.db) { const rows = this.db.prepare(` SELECT id, title, status, due_date, scheduled_start, scheduled_end, @@ -704,28 +795,17 @@ class TaskDatabase { ) `).all(startDate, endDate, startDate, endDate, startDate, endDate); - return rows.map(r => ({ - ...this._deserialize(r), - _eventType: r.completed_at ? 'task-completed' - : r.scheduled_start ? 'task-scheduled' - : 'task-due', - })); + for (const r of rows) { + processTask(this._deserialize(r)); + } + return events; } if (this.jsonState) { - return this.jsonState.tasks - .filter(t => { - const due = t.due_date && t.due_date >= startDate && t.due_date <= endDate; - const sched = t.scheduled_start && t.scheduled_start.slice(0, 10) >= startDate && t.scheduled_start.slice(0, 10) <= endDate; - const comp = t.completed_at && t.completed_at.slice(0, 10) >= startDate && t.completed_at.slice(0, 10) <= endDate; - return due || sched || comp; - }) - .map(t => ({ - ...t, - _eventType: t.completed_at ? 'task-completed' - : t.scheduled_start ? 'task-scheduled' - : 'task-due', - })); + for (const t of this.jsonState.tasks) { + processTask(t); + } + return events; } return []; diff --git a/electron/lib/tasks/taskIpc.cjs b/electron/lib/tasks/taskIpc.cjs index db1cbb64..5fb8d36c 100644 --- a/electron/lib/tasks/taskIpc.cjs +++ b/electron/lib/tasks/taskIpc.cjs @@ -19,7 +19,65 @@ function parseMarkdownTasks(content) { while ((m = re.exec(src)) !== null) { const line = src.slice(0, m.index).split(/\r?\n/).length; const lineText = m[0].trim(); - tasks.push({ title: (m[1] || '').trim(), status, line, lineText }); + const rawTitle = (m[1] || '').trim(); + + let priority = 0; + if (/#p1\b/i.test(rawTitle) || /#urgent\b/i.test(rawTitle)) priority = 3; + else if (/#p2\b/i.test(rawTitle) || /#high\b/i.test(rawTitle)) priority = 2; + else if (/#p3\b/i.test(rawTitle) || /#medium\b/i.test(rawTitle)) priority = 1; + + let dueDate = null; + let scheduledStart = null; + let scheduledEnd = null; + let isAllDay = 1; + let completedAt = null; + + const dueMatch = rawTitle.match(/@due\(([^)]+)\)/i) || rawTitle.match(/\bdue:([0-9T:\- ]+)/i); + if (dueMatch) { + const val = dueMatch[1].trim(); + dueDate = val.slice(0, 10); + if (val.length > 10) { + scheduledStart = val; + isAllDay = 0; + } + } + + const schedMatch = rawTitle.match(/@sched(?:ule|uled)?\(([^)]+)\)/i) || rawTitle.match(/\bsched(?:ule|uled)?:([0-9T:\- ]+)/i); + if (schedMatch) { + const val = schedMatch[1].trim(); + scheduledStart = val; + if (!dueDate) dueDate = val.slice(0, 10); + if (val.length > 10) isAllDay = 0; + } + + const startMatch = rawTitle.match(/@start\(([^)]+)\)/i); + if (startMatch) { + scheduledStart = startMatch[1].trim(); + if (scheduledStart.length > 10) isAllDay = 0; + } + + const endMatch = rawTitle.match(/@end\(([^)]+)\)/i); + if (endMatch) { + scheduledEnd = endMatch[1].trim(); + } + + const doneMatch = rawTitle.match(/@(?:completed|done)\(([^)]+)\)/i); + if (doneMatch) { + completedAt = doneMatch[1].trim(); + } + + tasks.push({ + title: rawTitle, + status, + line, + lineText, + priority, + due_date: dueDate, + scheduled_start: scheduledStart, + scheduled_end: scheduledEnd, + is_all_day: isAllDay, + completed_at: completedAt, + }); } }; @@ -331,7 +389,62 @@ function registerTaskIpc(ipcMain, deps) { return db ? db.deletePerson(id) : false; }); - // ── Calendar ────────────────────────────────────────────────────────────── + // Helper to format Date or ISO string into local YYYY-MM-DD + function toLocalDateStr(val) { + if (!val) return ''; + const d = new Date(val); + if (isNaN(d.getTime())) return ''; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + // Extract note frontmatter and fs creation/update timestamps + function extractNoteDates(filePath, content, stat) { + let createdIso = null; + let updatedIso = null; + let createdIsAllDay = false; + let updatedIsAllDay = false; + + if (content) { + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (fmMatch) { + const fm = fmMatch[1]; + const createdMatch = fm.match(/(?:created|createdAt|created_at|date|publishedAt):\s*["']?([^"'\r\n]+)["']?/i); + if (createdMatch) { + const raw = createdMatch[1].trim(); + const d = new Date(raw); + if (!isNaN(d.getTime())) { + createdIso = d.toISOString(); + if (!raw.includes(':') && !raw.includes('T')) createdIsAllDay = true; + } + } + const updatedMatch = fm.match(/(?:updated|updatedAt|updated_at|modified|lastModified):\s*["']?([^"'\r\n]+)["']?/i); + if (updatedMatch) { + const raw = updatedMatch[1].trim(); + const d = new Date(raw); + if (!isNaN(d.getTime())) { + updatedIso = d.toISOString(); + if (!raw.includes(':') && !raw.includes('T')) updatedIsAllDay = true; + } + } + } + } + + if (!createdIso && stat) { + const btime = stat.birthtime && !isNaN(stat.birthtime.getTime()) && stat.birthtime.getTime() > 0 ? stat.birthtime : stat.ctime; + if (btime && !isNaN(btime.getTime())) { + createdIso = btime.toISOString(); + } + } + + if (!updatedIso && stat && stat.mtime && !isNaN(stat.mtime.getTime())) { + updatedIso = stat.mtime.toISOString(); + } + + return { createdIso, updatedIso, createdIsAllDay, updatedIsAllDay }; + } trusted('calendar:get-events', (_event, { startDate, endDate, types = [] } = {}) => { const db = getDb(); @@ -345,16 +458,18 @@ function registerTaskIpc(ipcMain, deps) { // Task events const taskEvents = db.getCalendarTaskEvents(startDate, endDate); for (const t of taskEvents) { - if (!types.length || types.includes(t._eventType)) { + const eventType = t._eventType || t.type || 'task-due'; + if (!types.length || types.includes(eventType)) { events.push({ - id: `task-${t.id}`, + id: t.id || `task-${t.taskId || Math.random()}`, title: t.title, - start: t.scheduled_start || t.due_date || t.completed_at, - end: t.scheduled_end || t.due_date || t.completed_at, - allDay: Boolean(t.isAllDay), - type: t._eventType, - taskId: t.id, - sourcePath: t.source_path, + start: t.start || t.scheduled_start || t.due_date || t.completed_at, + end: t.end || t.scheduled_end || t.start || t.due_date, + allDay: Boolean(t.allDay ?? t.isAllDay), + type: eventType, + _eventType: eventType, + taskId: t.taskId || t.id, + sourcePath: t.source_path || t.sourcePath, priority: t.priority, status: t.status, }); @@ -376,36 +491,40 @@ function registerTaskIpc(ipcMain, deps) { } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) { try { const stat = fs.statSync(fullPath); + let content = ''; + try { content = fs.readFileSync(fullPath, 'utf8'); } catch { /* ignore */ } const title = entry.name.replace(/\.md$/i, ''); - const createdStr = (stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime : stat.ctime).toISOString(); - const updatedStr = stat.mtime.toISOString(); - const createdDate = createdStr.slice(0, 10); - const updatedDate = updatedStr.slice(0, 10); + const noteDates = extractNoteDates(fullPath, content, stat); + + const createdDate = toLocalDateStr(noteDates.createdIso); + const updatedDate = toLocalDateStr(noteDates.updatedIso); - if (createdDate >= startDate && createdDate <= endDate) { + if (createdDate && (!startDate || createdDate >= startDate) && (!endDate || createdDate <= endDate)) { if (!types.length || types.includes('note-created')) { events.push({ id: `note-created-${fullPath}`, title: `Created: ${title}`, - start: createdStr, - end: createdStr, - allDay: true, + start: noteDates.createdIso, + end: noteDates.createdIso, + allDay: noteDates.createdIsAllDay, type: 'note-created', + _eventType: 'note-created', sourcePath: fullPath, filePath: fullPath, }); } } - if (updatedDate >= startDate && updatedDate <= endDate) { + if (updatedDate && (!startDate || updatedDate >= startDate) && (!endDate || updatedDate <= endDate)) { if (!types.length || types.includes('note-updated')) { events.push({ id: `note-updated-${fullPath}`, title: `Updated: ${title}`, - start: updatedStr, - end: updatedStr, - allDay: true, + start: noteDates.updatedIso, + end: noteDates.updatedIso, + allDay: noteDates.updatedIsAllDay, type: 'note-updated', + _eventType: 'note-updated', sourcePath: fullPath, filePath: fullPath, }); diff --git a/electron/mcp/McpLifecycle.cjs b/electron/mcp/McpLifecycle.cjs index 0e74bb8f..225e031d 100644 --- a/electron/mcp/McpLifecycle.cjs +++ b/electron/mcp/McpLifecycle.cjs @@ -6,6 +6,7 @@ const { McpConfig } = require('./McpConfig.cjs'); const { McpSessionManager } = require('./McpSessionManager.cjs'); const { McpServer } = require('./McpServer.cjs'); +const { mcpPromptsRegistry } = require('./McpPrompts.cjs'); class McpLifecycle { constructor() { @@ -202,6 +203,12 @@ class McpLifecycle { }; } + broadcastResourceUpdated(uri) { + if (this.server && typeof this.server.broadcastResourceUpdated === 'function') { + this.server.broadcastResourceUpdated(uri); + } + } + registerIpcHandlers(ipcMain) { ipcMain.handle('mcp:get-status', async () => { return this.getStatus(); @@ -233,6 +240,26 @@ class McpLifecycle { stats: this.sessionManager.getStats() }; }); + + ipcMain.handle('mcp:list-prompts', async () => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.listPrompts(root); + }); + + ipcMain.handle('mcp:get-prompt', async (_event, name, args) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.getPrompt(name, args || {}, root); + }); + + ipcMain.handle('mcp:save-prompt', async (_event, promptData) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.saveCustomPrompt(root, promptData); + }); + + ipcMain.handle('mcp:delete-prompt', async (_event, name) => { + const root = typeof this.getWorkspaceRoot === 'function' ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.deleteCustomPrompt(root, name); + }); } async shutdown() { diff --git a/electron/mcp/McpPrompts.cjs b/electron/mcp/McpPrompts.cjs index 3dfaa56b..6c0739d8 100644 --- a/electron/mcp/McpPrompts.cjs +++ b/electron/mcp/McpPrompts.cjs @@ -164,34 +164,256 @@ Provide an executive daily briefing: } ]; +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + class McpPromptsRegistry { constructor() { - this.prompts = new Map(); + this.builtinPrompts = new Map(); for (const p of ENTERPRISE_PROMPTS) { - this.prompts.set(p.name, p); + this.builtinPrompts.set(p.name, { ...p, source: 'builtin' }); + } + } + + /** + * Scan and load custom markdown prompts from /.notes-app/prompts/*.md + * @param {string} workspaceRoot + * @returns {Map} + */ + loadWorkspacePrompts(workspaceRoot) { + const promptsMap = new Map(); + if (!workspaceRoot || typeof workspaceRoot !== 'string') { + return promptsMap; + } + + const promptsDir = path.join(workspaceRoot, '.notes-app', 'prompts'); + if (!fs.existsSync(promptsDir)) { + return promptsMap; + } + + try { + const files = fs.readdirSync(promptsDir); + for (const file of files) { + if (!file.endsWith('.md')) continue; + const filePath = path.join(promptsDir, file); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = this.parsePromptFile(raw, file); + if (parsed) { + parsed.filePath = filePath; + parsed.fileName = file; + parsed.source = 'workspace'; + promptsMap.set(parsed.name, parsed); + } + } catch (err) { + console.warn(`[McpPrompts] Failed to parse custom prompt file "${file}":`, err.message); + } + } + } catch (err) { + console.warn(`[McpPrompts] Error reading prompts directory "${promptsDir}":`, err.message); + } + + return promptsMap; + } + + /** + * Parse a Markdown prompt file with YAML frontmatter. + */ + parsePromptFile(rawContent, fallbackName = 'custom_prompt') { + const fmMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + let frontmatter = {}; + let templateBody = rawContent.trim(); + + if (fmMatch) { + try { + frontmatter = yaml.load(fmMatch[1]) || {}; + templateBody = (fmMatch[2] || '').trim(); + } catch (err) { + console.warn('[McpPrompts] YAML parse error:', err.message); + } } + + const name = String(frontmatter.name || path.basename(fallbackName, '.md')) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); + + const description = String(frontmatter.description || 'Workspace custom prompt workflow.').trim(); + const argsDef = Array.isArray(frontmatter.arguments) + ? frontmatter.arguments.map(arg => ({ + name: String(arg.name || '').trim(), + description: String(arg.description || '').trim(), + required: Boolean(arg.required) + })).filter(a => Boolean(a.name)) + : []; + + return { + name, + description, + arguments: argsDef, + template: templateBody, + generateMessages: (providedArgs = {}) => { + let text = templateBody; + // Interpolate {{argName}} or {{argName || "fallback"}} + text = text.replace(/\{\{\s*([a-zA-Z0-9_-]+)(?:\s*\|\|\s*["']([^"']*)["'])?\s*\}\}/g, (_match, varName, fallbackVal) => { + if (providedArgs[varName] !== undefined && providedArgs[varName] !== null && String(providedArgs[varName]).trim() !== '') { + return String(providedArgs[varName]); + } + return fallbackVal !== undefined ? fallbackVal : ''; + }); + + return [ + { + role: 'user', + content: { + type: 'text', + text + } + } + ]; + } + }; } - listPrompts() { - return Array.from(this.prompts.values()).map(p => ({ - name: p.name, - description: p.description, - arguments: p.arguments || [] - })); + /** + * List all prompts (Built-in + Workspace Custom). + * @param {string} [workspaceRoot] + */ + listPrompts(workspaceRoot = null) { + const results = []; + + // Built-ins + for (const p of this.builtinPrompts.values()) { + results.push({ + name: p.name, + description: p.description, + arguments: p.arguments || [], + source: 'builtin' + }); + } + + // Workspace custom + if (workspaceRoot) { + const wsPrompts = this.loadWorkspacePrompts(workspaceRoot); + for (const p of wsPrompts.values()) { + // If name collides with built-in, custom overrides or appears with source + const existingIdx = results.findIndex(r => r.name === p.name); + const item = { + name: p.name, + description: p.description, + arguments: p.arguments || [], + template: p.template || '', + source: 'workspace', + fileName: p.fileName + }; + if (existingIdx !== -1) { + results[existingIdx] = item; + } else { + results.push(item); + } + } + } + + return results; } - getPrompt(name, args = {}) { - const prompt = this.prompts.get(name); + /** + * Get and execute prompt message generator. + * @param {string} name + * @param {object} args + * @param {string} [workspaceRoot] + */ + getPrompt(name, args = {}, workspaceRoot = null) { + if (!name) throw new Error('Prompt name is required.'); + const cleanName = String(name).trim().toLowerCase(); + + // Check workspace prompts first + if (workspaceRoot) { + const wsPrompts = this.loadWorkspacePrompts(workspaceRoot); + if (wsPrompts.has(cleanName)) { + const prompt = wsPrompts.get(cleanName); + return { + description: prompt.description, + messages: prompt.generateMessages(args || {}) + }; + } + } + + // Fall back to built-ins + const prompt = this.builtinPrompts.get(cleanName); if (!prompt) { throw new Error(`Prompt "${name}" not found in MCP Prompts Registry.`); } - const messages = prompt.generateMessages(args || {}); return { description: prompt.description, - messages + messages: prompt.generateMessages(args || {}) + }; + } + + /** + * Save a workspace custom prompt to .notes-app/prompts/.md + * @param {string} workspaceRoot + * @param {object} promptData + */ + saveCustomPrompt(workspaceRoot, promptData = {}) { + if (!workspaceRoot) throw new Error('Workspace root is required to save custom prompt.'); + if (!promptData.name) throw new Error('Prompt name is required.'); + + const cleanName = String(promptData.name) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); + + const promptsDir = path.join(workspaceRoot, '.notes-app', 'prompts'); + if (!fs.existsSync(promptsDir)) { + fs.mkdirSync(promptsDir, { recursive: true }); + } + + const frontmatter = { + name: cleanName, + description: String(promptData.description || '').trim(), + arguments: Array.isArray(promptData.arguments) ? promptData.arguments : [] + }; + + const yml = yaml.dump(frontmatter).trim(); + const templateBody = String(promptData.template || '').trim(); + const fileContent = `---\n${yml}\n---\n\n${templateBody}\n`; + + const targetPath = path.join(promptsDir, `${cleanName}.md`); + const tmpPath = path.join(promptsDir, `.${cleanName}.${Date.now()}.tmp`); + + fs.writeFileSync(tmpPath, fileContent, 'utf8'); + fs.renameSync(tmpPath, targetPath); + + return { + success: true, + name: cleanName, + fileName: `${cleanName}.md`, + filePath: targetPath }; } + + /** + * Delete a workspace custom prompt from .notes-app/prompts/.md + * @param {string} workspaceRoot + * @param {string} name + */ + deleteCustomPrompt(workspaceRoot, name) { + if (!workspaceRoot) throw new Error('Workspace root is required.'); + if (!name) throw new Error('Prompt name is required.'); + + const cleanName = String(name).trim().toLowerCase().replace(/[^a-z0-9_-]/g, '_'); + const targetPath = path.join(workspaceRoot, '.notes-app', 'prompts', `${cleanName}.md`); + + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + return { success: true, name: cleanName, deleted: true }; + } + + return { success: false, name: cleanName, deleted: false, error: 'File not found' }; + } } const mcpPromptsRegistry = new McpPromptsRegistry(); diff --git a/electron/mcp/McpServer.cjs b/electron/mcp/McpServer.cjs index aad813dc..ccaa4aac 100644 --- a/electron/mcp/McpServer.cjs +++ b/electron/mcp/McpServer.cjs @@ -17,7 +17,9 @@ const { GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema } = require('@modelcontextprotocol/sdk/types.js'); const { applicationToolRegistry } = require('../tools/ApplicationToolRegistry.cjs'); const { mcpPromptsRegistry } = require('./McpPrompts.cjs'); @@ -55,6 +57,7 @@ class McpServer { this.httpServer = null; this.transports = new Map(); // sessionId -> { transport, server } (legacy SSE) this.streamableTransports = new Map(); // sessionId -> { transport, server } (Streamable HTTP) + this.resourceSubscriptions = new Map(); // sessionId -> Set this.isRunning = false; this.lastError = null; this.errorCode = null; @@ -78,6 +81,30 @@ class McpServer { return authHeader.trim() === `Bearer ${this.bearerToken.trim()}`; } + broadcastResourceUpdated(uri) { + if (!uri) return; + const notifySession = (sessionId, session) => { + const subs = this.resourceSubscriptions.get(sessionId); + if (subs && (subs.has(uri) || subs.has('*') || (uri.startsWith('notely://notes/') && (subs.has('notely://notes/*') || subs.has('notely://notes/{path}'))))) { + try { + if (session?.server && typeof session.server.notification === 'function') { + session.server.notification({ + method: 'notifications/resources/updated', + params: { uri } + }); + } + } catch { /* session closed */ } + } + }; + + for (const [sessionId, session] of this.streamableTransports.entries()) { + notifySession(sessionId, session); + } + for (const [sessionId, session] of this.transports.entries()) { + notifySession(sessionId, session); + } + } + _createServerInstance(sessionIdOrFn) { const getActiveSessionId = () => { if (typeof sessionIdOrFn === 'function') { @@ -88,7 +115,7 @@ class McpServer { const server = new Server( { name: 'notely', version: appVersion }, - { capabilities: { tools: {}, prompts: {}, resources: {} } } + { capabilities: { tools: {}, prompts: {}, resources: { subscribe: true, listChanged: true } } } ); server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -100,12 +127,33 @@ class McpServer { }); server.setRequestHandler(ListPromptsRequestSchema, async () => { - return { prompts: mcpPromptsRegistry.listPrompts() }; + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; + return { prompts: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot) }; }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; - return mcpPromptsRegistry.getPrompt(name, args || {}); + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; + return mcpPromptsRegistry.getPrompt(name, args || {}, activeWorkspaceRoot); + }); + + server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const { uri } = request.params; + const sessionId = getActiveSessionId(); + if (!this.resourceSubscriptions.has(sessionId)) { + this.resourceSubscriptions.set(sessionId, new Set()); + } + this.resourceSubscriptions.get(sessionId).add(uri); + return {}; + }); + + server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + const { uri } = request.params; + const sessionId = getActiveSessionId(); + if (this.resourceSubscriptions.has(sessionId)) { + this.resourceSubscriptions.get(sessionId).delete(uri); + } + return {}; }); server.setRequestHandler(ListResourcesRequestSchema, async () => { @@ -327,13 +375,14 @@ class McpServer { const advertisedSchemas = this.allowWriteTools ? allSchemas : allSchemas.filter(t => !t.isWrite); + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; res.end(JSON.stringify({ status: 'ok', server: 'notely-mcp', version: '0.1.41', port: this.port, toolsCount: advertisedSchemas.length, - promptsCount: mcpPromptsRegistry.listPrompts().length, + promptsCount: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot).length, resourcesCount: 2, activeSessions: (this.sessionManager ? this.sessionManager.getActiveSessions().length : 0) + this.streamableTransports.size })); @@ -363,8 +412,9 @@ class McpServer { res.end(JSON.stringify({ error: 'Unauthorized: invalid or missing Bearer token' })); return; } + const activeWorkspaceRoot = this.getWorkspaceRoot ? this.getWorkspaceRoot() : null; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ prompts: mcpPromptsRegistry.listPrompts() })); + res.end(JSON.stringify({ prompts: mcpPromptsRegistry.listPrompts(activeWorkspaceRoot) })); return; } diff --git a/electron/preload.cjs b/electron/preload.cjs index 7522e52a..dec2e4b4 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -72,6 +72,10 @@ contextBridge.exposeInMainWorld("notesApi", { mcpStop: () => ipcRenderer.invoke("mcp:stop"), mcpRestart: () => ipcRenderer.invoke("mcp:restart"), mcpGetSessions: () => ipcRenderer.invoke("mcp:get-sessions"), + mcpListPrompts: () => ipcRenderer.invoke("mcp:list-prompts"), + mcpGetPrompt: (name, args) => ipcRenderer.invoke("mcp:get-prompt", name, args), + mcpSavePrompt: (promptData) => ipcRenderer.invoke("mcp:save-prompt", promptData), + mcpDeletePrompt: (name) => ipcRenderer.invoke("mcp:delete-prompt", name), onMcpStatusChanged: (callback) => { if (typeof callback !== "function") return () => {}; const listener = (_event, payload) => callback(payload); diff --git a/electron/services/KnowledgeApplicationService.cjs b/electron/services/KnowledgeApplicationService.cjs index a9d484b4..e0f2e5ef 100644 --- a/electron/services/KnowledgeApplicationService.cjs +++ b/electron/services/KnowledgeApplicationService.cjs @@ -138,36 +138,55 @@ class KnowledgeApplicationService { } /** - * Hybrid search combining full-text search and graph/vector results. + * Hybrid search combining full-text lexical search and vector semantic similarity + * using Reciprocal Rank Fusion (RRF: 1 / (k + rank)). */ - async searchHybrid({ workspaceRoot, query, limit = 10 }) { - const ftsResults = await this.searchNotes({ workspaceRoot, query, limit }); - const similarResults = await this.searchSimilar({ workspaceRoot, text: query, topK: limit }); + async searchHybrid({ workspaceRoot, query, limit = 10, rrfK = 60 }) { + const ftsResults = await this.searchNotes({ workspaceRoot, query, limit: Math.max(limit * 2, 20) }); + const similarResults = await this.searchSimilar({ workspaceRoot, text: query, topK: Math.max(limit * 2, 20) }); - const combinedMap = new Map(); + const scoreMap = new Map(); - for (const item of ftsResults) { - combinedMap.set(item.path, { + // 1. Accumulate Lexical FTS RRF ranks + ftsResults.forEach((item, index) => { + const rank = index + 1; + const rrfContribution = 1.0 / (rrfK + rank); + scoreMap.set(item.path, { path: item.path, - compositeScore: item.score * 0.6, - snippet: item.snippet + title: item.title || path.basename(item.path), + compositeScore: rrfContribution, + ftsRank: rank, + semanticRank: null, + snippet: item.snippet || "" }); - } + }); - for (const item of similarResults) { - const existing = combinedMap.get(item.path); - if (existing) { - existing.compositeScore += item.similarity * 0.4; + // 2. Accumulate Vector Semantic RRF ranks + similarResults.forEach((item, index) => { + const rank = index + 1; + const rrfContribution = 1.0 / (rrfK + rank); + if (scoreMap.has(item.path)) { + const existing = scoreMap.get(item.path); + existing.compositeScore += rrfContribution; + existing.semanticRank = rank; + if (!existing.snippet && item.snippet) { + existing.snippet = item.snippet; + } } else { - combinedMap.set(item.path, { + scoreMap.set(item.path, { path: item.path, - compositeScore: item.similarity * 0.4, - snippet: item.snippet + title: item.title || path.basename(item.path), + compositeScore: rrfContribution, + ftsRank: null, + semanticRank: rank, + snippet: item.snippet || "" }); } - } + }); + + const sorted = Array.from(scoreMap.values()) + .sort((a, b) => b.compositeScore - a.compositeScore); - const sorted = Array.from(combinedMap.values()).sort((a, b) => b.compositeScore - a.compositeScore); return sorted.slice(0, Math.min(limit, 50)); } diff --git a/electron/tools/EnterpriseToolSuite.cjs b/electron/tools/EnterpriseToolSuite.cjs index eecc44e6..a1e023c2 100644 --- a/electron/tools/EnterpriseToolSuite.cjs +++ b/electron/tools/EnterpriseToolSuite.cjs @@ -40,6 +40,22 @@ class EnterpriseToolSuite { get workspaceService() { return this.registry.workspaceService; } get webService() { return this.registry.webService; } + _notifyResourceChange(workspaceRoot, noteRelPath = null) { + try { + const { mcpLifecycle } = require('../mcp/McpLifecycle.cjs'); + if (mcpLifecycle && typeof mcpLifecycle.broadcastResourceUpdated === 'function') { + if (noteRelPath) { + mcpLifecycle.broadcastResourceUpdated(`notely://notes/${encodeURIComponent(noteRelPath)}`); + mcpLifecycle.broadcastResourceUpdated(`notely://notes/${noteRelPath}`); + } + mcpLifecycle.broadcastResourceUpdated('notely://workspace/tree'); + mcpLifecycle.broadcastResourceUpdated('notely://workspace/stats'); + } + } catch { + // Non-blocking notification + } + } + // ─── 1. SEARCH ───────────────────────────────────────────────────────────── async search(args = {}, context = {}) { @@ -80,7 +96,90 @@ class EnterpriseToolSuite { } } - // 2. Local Notes Search + // 2. Code Symbols Search (when source is "code" or "all") + if (source === 'code' || source === 'all') { + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + gdb.exec('PRAGMA busy_timeout = 5000;'); + try { + const codeTypes = ['CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo']; + const placeholders = codeTypes.map(() => '?').join(','); + const rows = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE type IN (${placeholders}) + `).all(...codeTypes); + + const queryTokens = query.toLowerCase().replace(/[^a-z0-9_\-\s]/g, ' ').split(/\s+/).filter(t => t.length >= 2); + const codeHits = []; + + for (const row of rows) { + const name = row.name || ''; + const canonical = row.canonical_name || ''; + let props = {}; + try { props = JSON.parse(row.properties || '{}'); } catch { /* ignore */ } + + let score = 0; + const matchReasons = { titleMatch: false, textSnippets: [], tagMatches: [] }; + + if (query) { + const lowerQ = query.toLowerCase(); + if (name.toLowerCase() === lowerQ || canonical.toLowerCase() === lowerQ) { + score += 3.0; + matchReasons.titleMatch = true; + } else if (name.toLowerCase().includes(lowerQ) || canonical.toLowerCase().includes(lowerQ)) { + score += 2.0; + matchReasons.titleMatch = true; + } else if (props.filePath && props.filePath.toLowerCase().includes(lowerQ)) { + score += 1.5; + matchReasons.textSnippets.push(`File: ${props.filePath}`); + } else { + for (const tok of queryTokens) { + if (name.toLowerCase().includes(tok) || canonical.toLowerCase().includes(tok)) { + score += 0.8; + } + } + } + } else { + score = 1.0; + } + + if (score > 0) { + codeHits.push({ + path: props.filePath || row.name, + title: row.name, + canonicalName: row.canonical_name, + type: row.type, + repoName: props.repoName || props.repoId || 'code', + score: Number(score.toFixed(2)), + matchReasons, + properties: props + }); + } + } + + codeHits.sort((a, b) => b.score - a.score); + if (source === 'code') { + results.hits = codeHits.slice(0, limit); + results.totalHits = codeHits.length; + return results; + } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } catch (err) { + if (source === 'code') { + results.codeError = err.message; + return results; + } + } + } + + // 3. Local Notes Search const allFiles = collectMarkdownFiles(workspaceRoot); const scoredHits = []; @@ -499,6 +598,43 @@ class EnterpriseToolSuite { // Git history const gitHistory = includeGit ? getFileGitHistory(fullPath, workspaceRoot, 5) : []; + // Repo symbol resolution + let repoSymbols = undefined; + if (inc.repoSymbols !== false) { + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + gdb.exec('PRAGMA busy_timeout = 5000;'); + try { + const wikiMatches = (rawContent.match(/\[\[([^|\]]+)(?:\|[^\]]+)?\]\]/g) || []) + .map(m => m.replace(/^\[\[|\]\]$/g, '').split('|')[0].trim()); + if (wikiMatches.length > 0) { + const placeholders = wikiMatches.map(() => '?').join(','); + const matchedEntities = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE (name IN (${placeholders}) OR canonical_name IN (${placeholders})) + AND type IN ('CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo') + `).all(...wikiMatches, ...wikiMatches); + + if (matchedEntities.length > 0) { + repoSymbols = matchedEntities.map(ent => ({ + name: ent.name, + canonicalName: ent.canonical_name, + type: ent.type, + properties: typeof ent.properties === 'string' ? JSON.parse(ent.properties || '{}') : ent.properties + })); + } + } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } catch { /* ignore graph read error */ } + } + // Stats const words = cleanMarkdown(rawContent).split(/\s+/).filter(Boolean).length; const readingTimeMin = Math.max(1, Math.ceil(words / 200)); @@ -535,6 +671,7 @@ class EnterpriseToolSuite { media: includeMedia ? media : undefined, links: includeLinks ? links : undefined, gitHistory: includeGit ? gitHistory : undefined, + repoSymbols, suggestedFollowUps: [ { action: 'edit_note', @@ -574,6 +711,7 @@ class EnterpriseToolSuite { } if (!dryRun) { await safeDeleteFile(targetAbsPath); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } return { path: toWorkspaceRelative(targetAbsPath, workspaceRoot), @@ -597,6 +735,8 @@ class EnterpriseToolSuite { const destDir = path.dirname(newAbsPath); if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }); fs.renameSync(targetAbsPath, newAbsPath); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(newAbsPath, workspaceRoot)); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } return { previousPath: toWorkspaceRelative(targetAbsPath, workspaceRoot), @@ -754,6 +894,7 @@ class EnterpriseToolSuite { if (!dryRun) { atomicWriteFile(targetAbsPath, updatedContent, options.backup); + this._notifyResourceChange(workspaceRoot, toWorkspaceRelative(targetAbsPath, workspaceRoot)); } const relPath = toWorkspaceRelative(targetAbsPath, workspaceRoot); @@ -1161,7 +1302,7 @@ class EnterpriseToolSuite { const workspaceRoot = context.workspaceRoot || args.workspaceRoot; if (!workspaceRoot) throw new Error('Workspace root is required.'); - const operation = (args.operation || 'summary').toLowerCase(); + const operation = String(args.operation || (args.action ? 'repos' : 'summary')).toLowerCase(); // 1. SUMMARY if (operation === 'summary') { @@ -1206,12 +1347,47 @@ class EnterpriseToolSuite { } catch { /* skip */ } } + // Pull code entities and relationships from ai-graph.db if present + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + gdb.exec('PRAGMA busy_timeout = 5000;'); + try { + const codeRows = gdb.prepare(` + SELECT id, name, canonical_name, type, properties + FROM entities + WHERE type IN ('CodeModule', 'CodeClass', 'CodeInterface', 'CodeFunction', 'APIEndpoint', 'DBModel', 'Repo') + LIMIT 150 + `).all(); + for (const cr of codeRows) { + if (!seenNodes.has(cr.id)) { + seenNodes.add(cr.id); + nodes.push({ id: cr.id, title: cr.name, type: cr.type, canonicalName: cr.canonical_name }); + } + } + + const relRows = gdb.prepare(` + SELECT source_id, target_id, type, weight, confidence + FROM relationships + LIMIT 300 + `).all(); + for (const rr of relRows) { + edges.push({ from: rr.source_id, to: rr.target_id, relation: rr.type, weight: rr.weight, confidence: rr.confidence }); + } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } catch { /* ignore graph read error */ } + return { operation: 'graph', totalNodes: nodes.length, totalEdges: edges.length, - nodes: nodes.slice(0, 100), - edges: edges.slice(0, 200) + nodes: nodes.slice(0, 200), + edges: edges.slice(0, 400) }; } @@ -1262,6 +1438,11 @@ class EnterpriseToolSuite { return this.workspaceService.getNotesIndex({ workspaceRoot, folder: args.folder }); } + // 7. REPOS + if (operation === 'repos') { + return this.manageRepositories(args, context); + } + throw new Error(`Unknown workspace_overview operation: "${operation}".`); } @@ -1383,6 +1564,140 @@ class EnterpriseToolSuite { } } + // ─── 8. MANAGE REPOSITORIES ──────────────────────────────────────────────── + + async manageRepositories(args = {}, context = {}) { + const workspaceRoot = context.workspaceRoot || args.workspaceRoot; + if (!workspaceRoot) throw new Error('Workspace root is required.'); + + const action = String(args.action || 'list').toLowerCase(); + const metaPath = path.join(workspaceRoot, '.notes-app', 'metadata.json'); + + const loadMeta = () => { + if (fs.existsSync(metaPath)) { + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + } catch { return { attachedRepos: [] }; } + } + return { attachedRepos: [] }; + }; + + const saveMeta = (data) => { + const dir = path.dirname(metaPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(metaPath, JSON.stringify(data, null, 2), 'utf8'); + }; + + switch (action) { + case 'list': { + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + return { operation: 'repos', action: 'list', count: repos.length, repositories: repos }; + } + + case 'add': { + const repoPath = args.path || args.repoPath; + if (!repoPath) throw new Error('path is required for adding repository.'); + const normPath = path.resolve(repoPath); + if (!fs.existsSync(normPath)) throw new Error(`Repository path "${repoPath}" does not exist on disk.`); + + const meta = loadMeta(); + if (!Array.isArray(meta.attachedRepos)) meta.attachedRepos = []; + + const existing = meta.attachedRepos.find(r => r.path === normPath || r.id === args.id); + if (existing) { + return { operation: 'repos', action: 'add', success: true, message: 'Repository already attached', repo: existing, repositories: meta.attachedRepos }; + } + + const crypto = require('crypto'); + const repoId = args.id || `repo-${crypto.randomBytes(4).toString('hex')}`; + const repoName = args.name || path.basename(normPath); + const branch = args.branch || 'main'; + + const newRepo = { + id: repoId, + name: repoName, + path: normPath, + branch, + attachedAt: new Date().toISOString(), + status: 'unindexed', + symbolCount: 0 + }; + + meta.attachedRepos.push(newRepo); + saveMeta(meta); + + return { operation: 'repos', action: 'add', success: true, repo: newRepo, repositories: meta.attachedRepos }; + } + + case 'remove': { + const repoId = args.repoId || args.id || args.path; + if (!repoId) throw new Error('repoId or path is required for removing repository.'); + + const meta = loadMeta(); + if (!Array.isArray(meta.attachedRepos)) meta.attachedRepos = []; + + const targetRepo = meta.attachedRepos.find(r => r.id === repoId || r.path === repoId); + meta.attachedRepos = meta.attachedRepos.filter(r => r.id !== repoId && r.path !== repoId); + saveMeta(meta); + + // Purge matching entities in graph db + try { + const dbPath = path.join(workspaceRoot, '.notes-app', 'ai-graph.db'); + if (fs.existsSync(dbPath)) { + const { DatabaseSync } = require('node:sqlite'); + const gdb = new DatabaseSync(dbPath); + gdb.exec('PRAGMA busy_timeout = 5000;'); + try { + gdb.exec('BEGIN'); + if (targetRepo?.id) { + gdb.prepare('DELETE FROM entities WHERE properties LIKE ?').run(`%"repoId":"${targetRepo.id}"%`); + } + if (targetRepo?.path) { + gdb.prepare('DELETE FROM entities WHERE properties LIKE ?').run(`%"absolutePath":"${targetRepo.path.replace(/\\/g, '\\\\')}%`); + } + gdb.exec('COMMIT'); + } catch { + try { gdb.exec('ROLLBACK'); } catch { /* ignore */ } + } finally { + try { gdb.close(); } catch { /* ignore */ } + } + } + } catch { /* ignore graph purge error */ } + + return { operation: 'repos', action: 'remove', success: true, removedRepoId: repoId, repositories: meta.attachedRepos }; + } + + case 'status': { + const repoId = args.repoId || args.id || args.path; + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + if (repoId) { + const repo = repos.find(r => r.id === repoId || r.path === repoId); + if (!repo) throw new Error(`Repository "${repoId}" not found.`); + return { operation: 'repos', action: 'status', repo }; + } + return { operation: 'repos', action: 'status', repositories: repos }; + } + + case 'refresh': + case 'rescan': { + const meta = loadMeta(); + const repos = Array.isArray(meta.attachedRepos) ? meta.attachedRepos : []; + return { + operation: 'repos', + action, + success: true, + message: 'Repository scan requested. Use aiBuildGraph to execute full indexing.', + repositories: repos + }; + } + + default: + throw new Error(`Unknown manage_repositories action: "${action}". Supported actions: [list, add, remove, status, refresh].`); + } + } + // ─── ENTERPRISE TOOL DEFINITIONS FOR MCP REGISTRATION ───────────────────── getToolDefinitions() { @@ -1406,9 +1721,9 @@ class EnterpriseToolSuite { }, source: { type: 'string', - enum: ['notes', 'web', 'all'], + enum: ['notes', 'code', 'web', 'all'], default: 'notes', - description: 'Where to search: "notes" (workspace notes), "web" (external web search), or "all" (both)' + description: 'Where to search: "notes" (workspace notes), "code" (code entities and AST symbols), "web" (external web search), or "all"' }, mode: { type: 'string', @@ -1466,6 +1781,7 @@ class EnterpriseToolSuite { diagrams: { type: 'boolean', default: true, description: 'Include Mermaid diagrams and linked drawing files' }, tasks: { type: 'boolean', default: true, description: 'Include checklist tasks with status and due dates' }, links: { type: 'boolean', default: true, description: 'Include outgoing wikilinks and backlinks from other notes' }, + repoSymbols: { type: 'boolean', default: true, description: 'Include linked code symbols referenced in this note' }, gitHistory: { type: 'boolean', default: true, description: 'Include recent git commits modifying this note' }, stats: { type: 'boolean', default: true, description: 'Include word count, line count, and reading time' } } @@ -1640,9 +1956,9 @@ class EnterpriseToolSuite { name: 'workspace_overview', version: 'v1', capability: 'graph:traverse', - aliases: ['explore_topic_graph', 'get_graph'], + aliases: ['explore_topic_graph', 'get_graph', 'manage_repositories', 'manage_repos', 'attached_repos'], informationNeeds: ['entity_relationships', 'knowledge_graph', 'workspace_metadata'], - description: 'Workspace intelligence, structure, health, and diagnostics. Returns hierarchical folder trees, knowledge graph relationships, disk storage stats, link integrity audits (broken wikilinks), and recent file activity.', + description: 'Workspace intelligence, structure, health, and diagnostics. Returns hierarchical folder trees, knowledge graph relationships, attached repository summaries, disk storage stats, link integrity audits (broken wikilinks), and recent file activity.', isWrite: false, annotations: { readOnly: true, idempotent: true }, execute: async (args, context) => this.workspaceOverview(args, context), @@ -1651,9 +1967,29 @@ class EnterpriseToolSuite { properties: { operation: { type: 'string', - enum: ['summary', 'tree', 'graph', 'lint', 'index', 'recent_activity'], - default: 'summary', - description: '"summary" (health & note count), "tree" (folder/file hierarchy), "graph" (wikilink nodes & edges), "lint" (audit broken wikilinks & empty notes), "index" (structured notes catalog), "recent_activity" (recent modified notes)' + enum: ['summary', 'tree', 'graph', 'repos', 'lint', 'index', 'recent_activity'], + description: '"summary" (health & note count), "tree" (folder/file hierarchy), "graph" (wikilink & code entity graph), "repos" (attached git repositories status), "lint" (audit broken wikilinks & empty notes), "index" (structured notes catalog), "recent_activity" (recent modified notes)' + }, + action: { + type: 'string', + enum: ['list', 'add', 'remove', 'status', 'refresh'], + description: 'Action for "repos" operation: "list" (all attached repos), "add" (attach new local repo), "remove" (detach repo and purge graph symbols), "status" (inspect scan stats), "refresh" (request rescan)' + }, + path: { + type: 'string', + description: 'Local absolute directory path of the repository to attach' + }, + name: { + type: 'string', + description: 'Display alias name for the repository' + }, + branch: { + type: 'string', + description: 'Default Git branch name (default: main)' + }, + repoId: { + type: 'string', + description: 'Repository ID for "remove" or "status" actions' }, folder: { type: 'string', diff --git a/package-lock.json b/package-lock.json index a313297c..c218b395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notely", - "version": "0.1.47", + "version": "0.1.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notely", - "version": "0.1.47", + "version": "0.1.50", "hasInstallScript": true, "license": "CC-BY-NC-4.0", "dependencies": { @@ -25,7 +25,9 @@ "onnxruntime-node": "^1.27.0", "onnxruntime-web": "^1.27.0", "simple-git": "^3.36.0", + "tree-sitter-wasms": "^0.1.13", "uuid": "^14.0.1", + "web-tree-sitter": "^0.22.6", "xlsx": "^0.18.5", "yauzl": "^3.4.0", "yazl": "^3.3.1", @@ -17856,6 +17858,15 @@ "tree-kill": "cli.js" } }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -20358,6 +20369,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/web-tree-sitter": { + "version": "0.22.6", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.22.6.tgz", + "integrity": "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 85b63819..dcab7f6d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "onnxruntime-node": "^1.27.0", "onnxruntime-web": "^1.27.0", "simple-git": "^3.36.0", + "tree-sitter-wasms": "^0.1.13", "uuid": "^14.0.1", + "web-tree-sitter": "^0.22.6", "xlsx": "^0.18.5", "yauzl": "^3.4.0", "yazl": "^3.3.1", diff --git a/src/App.jsx b/src/App.jsx index 002f9e76..9a69a1e1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,7 +36,6 @@ const KeyboardShortcutsModal = lazy(() => import { GitStatusBar } from "./components/GitStatusBar"; import { AIStatusBar } from "./components/AIStatusBar"; -import { MCPStatusBar } from "./components/MCPStatusBar"; const NoteListPanel = lazy(() => import("./components/NoteListPanel").then((m) => ({ default: m.NoteListPanel })) @@ -69,6 +68,10 @@ import { getOnboardingComplete, getNotesRootSetting, gitGetStatus, + gitFetch, + gitPull, + gitPush, + gitListRemotes, checkForUpdates, onExportRecordAdded, listProjects, @@ -959,9 +962,36 @@ export default function App() { handleResolveConflict, handleOpenNextConflict, } = useP2PSync({ notify, setError, loadDocumentsData, syncStateRef }); - const [aiSettingsOpen, setAiSettingsOpen] = useState(false); const [mcpToolsPageOpen, setMcpToolsPageOpen] = useState(false); + const closeAllFullscreenViews = useCallback(() => { + setGraphPanelOpen(false); + setEmbeddingsPageOpen(false); + setHealthPageOpen(false); + setAppLogsOpen(false); + setGitVCOpen(false); + setTaskWorkspaceOpen(false); + setCalendarPageOpen(false); + setDownloadsPageOpen(false); + setWorkspaceIndexOpen(false); + setDiagramsMediaOpen(false); + setAttachedReposPageOpen(false); + setMcpToolsPageOpen(false); + }, [ + setGraphPanelOpen, + setEmbeddingsPageOpen, + setHealthPageOpen, + setAppLogsOpen, + setGitVCOpen, + setTaskWorkspaceOpen, + setCalendarPageOpen, + setDownloadsPageOpen, + setWorkspaceIndexOpen, + setDiagramsMediaOpen, + setAttachedReposPageOpen, + setMcpToolsPageOpen, + ]); + const handleAIEmbeddings = useCallback(async () => { notify("Generating embeddings...", "info"); try { @@ -1007,20 +1037,11 @@ export default function App() { useEffect(() => { if (p2pStatusOpen) { - setSettingsTab("p2p"); - setSettingsOpen(true); + openSettings("p2p"); setP2PStatusOpen(false); } }, [p2pStatusOpen, setP2PStatusOpen]); - useEffect(() => { - if (aiSettingsOpen) { - setSettingsTab("ai"); - setSettingsOpen(true); - setAiSettingsOpen(false); - } - }, [aiSettingsOpen, setAiSettingsOpen]); - const terminalCwd = current?.filePath ? current.filePath.replace(/[\\/][^\\/]+$/, "") : (landingFolderPath || activeProject?.rootPath || notesFolderPath); @@ -1103,16 +1124,25 @@ export default function App() { setDiagramsMediaOpen(false); } - const refreshGitWorkspaceMeta = useCallback(async function refreshGitWorkspaceMeta() { + const refreshGitWorkspaceMeta = useCallback(async function refreshGitWorkspaceMeta(triggerFetch = false) { try { const meta = await getGitWorkspaceMetadata(); let pendingCount = 0; let files = []; + let ahead = 0; + let behind = 0; if (meta?.isGitRoot && notesFolderPath) { + if (triggerFetch) { + try { + await gitFetch({ workspacePath: notesFolderPath }); + } catch { /* ignore background fetch errors */ } + } const statusResult = await gitGetStatus(notesFolderPath); if (statusResult?.ok) { pendingCount = statusResult.data.files?.length || 0; files = statusResult.data.files || []; + ahead = statusResult.data.ahead || 0; + behind = statusResult.data.behind || 0; } } setGitWorkspaceMeta({ @@ -1123,6 +1153,8 @@ export default function App() { gitignoreHasNotesApp: meta?.gitignoreHasNotesApp === true, pendingCount, files, + ahead, + behind, }); } catch { setGitWorkspaceMeta((currentValue) => ({ @@ -1130,10 +1162,49 @@ export default function App() { isGitRoot: false, pendingCount: 0, files: [], + ahead: 0, + behind: 0, })); } }, [notesFolderPath]); + // Periodic background fetch every 60 seconds when in a git workspace + useEffect(() => { + if (!notesFolderPath || !gitWorkspaceMeta.isGitRoot) return undefined; + const timer = setInterval(() => { + void refreshGitWorkspaceMeta(true); + }, 60000); + return () => clearInterval(timer); + }, [notesFolderPath, gitWorkspaceMeta.isGitRoot, refreshGitWorkspaceMeta]); + + const handleQuickGitSync = useCallback(async () => { + if (!notesFolderPath || !gitWorkspaceMeta.isGitRoot) return; + try { + const remotesRes = await gitListRemotes(notesFolderPath); + const remotes = remotesRes?.ok ? remotesRes.data : []; + if (!remotes || remotes.length === 0) { + notify("No remote connected. Configure a target in Revisions & Sync.", "info"); + setGitVCInitialTab("sync"); + setGitVCOpen(true); + return; + } + const primaryRemote = remotes[0].name || "origin"; + notify("Syncing workspace with cloud…", "info"); + const pullRes = await gitPull({ workspacePath: notesFolderPath, remote: primaryRemote }); + if (!pullRes?.ok) { + throw new Error(pullRes?.error || "Pull from cloud failed."); + } + const pushRes = await gitPush({ workspacePath: notesFolderPath, remote: primaryRemote }); + if (!pushRes?.ok) { + throw new Error(pushRes?.error || "Push to cloud failed."); + } + notify("Workspace synced with cloud successfully.", "success"); + void refreshGitWorkspaceMeta(true); + } catch (err) { + notify(err?.message || "Cloud sync failed.", "error"); + } + }, [notesFolderPath, gitWorkspaceMeta.isGitRoot, notify, refreshGitWorkspaceMeta, setGitVCOpen, setGitVCInitialTab]); + async function handleToggleAutoIgnoreGitMetadata() { try { const nextMeta = await setAutoIgnoreGitMetadata(!(gitWorkspaceMeta.autoIgnoreMetadataInGit !== false)); @@ -1154,13 +1225,15 @@ export default function App() { } } - const handleGitStateChange = useCallback(({ branch, pendingCount }) => { + const handleGitStateChange = useCallback(({ branch, pendingCount, ahead = 0, behind = 0 }) => { setGitWorkspaceMeta((meta) => { - if (meta.branch === branch && meta.pendingCount === pendingCount) return meta; + if (meta.branch === branch && meta.pendingCount === pendingCount && meta.ahead === ahead && meta.behind === behind) return meta; return { ...meta, branch, pendingCount, + ahead, + behind, }; }); }, []); @@ -1701,7 +1774,7 @@ export default function App() { } if (action === "open-p2p-status") { - handleOpenP2PStatus(); + openSettings("p2p"); return; } @@ -1711,16 +1784,6 @@ export default function App() { } - const closeAllFullscreenViews = () => { - setGraphPanelOpen(false); - setEmbeddingsPageOpen(false); - setHealthPageOpen(false); - setAppLogsOpen(false); - setGitVCOpen(false); - setTaskWorkspaceOpen(false); - setCalendarPageOpen(false); - }; - if (action === "open-tasks-workspace") { closeAllFullscreenViews(); setTaskWorkspaceContext(null); @@ -1734,7 +1797,7 @@ export default function App() { return; } - if (action === "open-workspace-graph") { + if (action === "open-knowledge-graph" || action === "open-workspace-graph") { closeAllFullscreenViews(); setGraphPanelOpen(true); return; @@ -1746,8 +1809,6 @@ export default function App() { return; } - - if (action === "open-app-logs") { closeAllFullscreenViews(); setAppLogsOpen(true); @@ -1761,7 +1822,7 @@ export default function App() { return; } - if (action === "open-ai-health") { + if (action === "open-diagnostics" || action === "open-health-page" || action === "open-ai-health" || action === "open-mcp-diagnostics") { closeAllFullscreenViews(); setHealthPageOpen(true); return; @@ -2168,13 +2229,12 @@ export default function App() { } if (action === "open-ai-settings") { - setAiSettingsOpen(true); + openSettings("ai"); return; } if (action === "open-mcp-settings") { - setSettingsTab("mcp"); - setSettingsOpen(true); + openSettings("mcp"); return; } @@ -2198,26 +2258,52 @@ export default function App() { - if (action === "open-health-page") { + if (action === "open-settings") { + openSettings("general"); + return; + } + + if (action === "open-health-page" || action === "open-diagnostics" || action === "open-mcp-diagnostics") { + closeAllFullscreenViews(); setHealthPageOpen(true); return; } + if (action === "open-trash") { + setTrashDialogOpen(true); + return; + } + if (action === "open-mcp-tools") { + closeAllFullscreenViews(); setMcpToolsPageOpen(true); return; } if (action === "open-workspace-index" || action === "workspace-index") { + closeAllFullscreenViews(); setWorkspaceIndexOpen(true); return; } if (action === "open-workspace-diagrams-media" || action === "workspace-diagrams-media") { + closeAllFullscreenViews(); setDiagramsMediaOpen(true); return; } + if (action === "open-attached-repos" || action === "attached-repos") { + closeAllFullscreenViews(); + setAttachedReposPageOpen(true); + return; + } + + if (action === "open-downloads-page" || action === "open-downloads") { + closeAllFullscreenViews(); + setDownloadsPageOpen(true); + return; + } + if ( action === "toggle-write-metadata-to-file" || action === "set-write-metadata-to-file-enabled" || @@ -2319,7 +2405,6 @@ export default function App() { const result = await exportWorkspaceZip(options); if (!result?.canceled) { setWorkspaceExportProgress({ phase: "Export complete", percent: 100 }); - notify(`Workspace exported: ${result?.filePath || "zip created"}`, "success"); setWorkspaceExportOpen(false); } } catch (error) { @@ -2436,13 +2521,12 @@ export default function App() { const paletteCommandsBase = [ { id: "restart-app", label: "Restart Notely", group: "App", aliases: "restart relaunch reboot app application" }, + { id: "open-settings", label: "Open Settings", group: "App", shortcut: "Ctrl/Cmd+,", aliases: "settings preferences general theme font zoom" }, { id: "new-note", label: "Create New Note", group: "Notes", shortcut: "Ctrl/Cmd+N", aliases: "add note new document write jot capture" }, - { id: "open-mcp-tools", label: "Open MCP Tools & Capabilities", group: "AI", shortcut: "Ctrl/Cmd+Shift+M", aliases: "mcp tools agent external server api" }, - + { id: "new-folder", label: "Create New Folder", group: "Notes", aliases: "add folder create directory organize" }, { id: "open-help-center", label: "Open Help Center", group: "Help", shortcut: "F1", aliases: "help docs guide manual about" }, { id: "open-feedback", label: "Report Bug / Feedback", group: "Help", aliases: "feedback bug report issue feature request" }, { id: "open-about", label: "Open About Notely", group: "Help", aliases: "about version build" }, - { id: "new-folder", label: "Create New Folder", group: "Notes", aliases: "add folder create directory organize" }, { id: "open-global-search", label: "Open Global Search", group: "Search", shortcut: "Ctrl/Cmd+Shift+F", aliases: "find everywhere search all notes quick open jump" }, { id: "open-shortcuts", label: "Open Keyboard Shortcuts", group: "Help", shortcut: "Ctrl/Cmd+/", aliases: "hotkeys keymap shortcuts" }, { id: "open-workspace", label: "Open Workspace", group: "Workspace", shortcut: "Ctrl/Cmd+Shift+N", aliases: "open workspace folder notes root path" }, @@ -2452,9 +2536,10 @@ export default function App() { { id: "export-workspace-zip", label: "Export Workspace as Zip", group: "Workspace", aliases: "export backup archive zip workspace" }, { id: "open-export-package", label: "Export Note Package...", group: "Package", aliases: "export note package nly note package backup share" }, { id: "open-import-package", label: "Import Note Package...", group: "Package", aliases: "import note package nly note package restore open" }, - { id: "open-workspace-graph", label: "Open Workspace Graph", group: "Navigation", aliases: "graph map relationships links topology" }, - { id: "open-tasks-panel", label: "Open Tasks Panel", group: "Navigation", aliases: "tasks todos checkboxes unchecked open items" }, - { id: "open-all-tasks", label: "Open All Tasks", group: "Navigation", aliases: "all tasks completed closed open task list workspace tasks" }, + { id: "open-tasks-workspace", label: "Open Tasks", group: "Workspace", shortcut: "Ctrl/Cmd+Shift+T", aliases: "tasks todos checkboxes unchecked open items task workspace" }, + { id: "open-calendar", label: "Open Calendar", group: "Workspace", aliases: "calendar schedule dates" }, + { id: "open-trash", label: "Open Trash", group: "Workspace", aliases: "trash deleted removed notes bin" }, + { id: "open-workspace-index", label: "Open Workspace Index", group: "Workspace", shortcut: "Ctrl/Cmd+Alt+I", aliases: "workspace index outline headings tags notes list" }, { id: "open-recent-workspaces", label: "Open Recent Workspace", @@ -2462,15 +2547,19 @@ export default function App() { disabled: recentWorkspacePaths.length === 0, aliases: "recent workspaces recently opened folders", }, - { id: "open-workspace-diagrams-media", label: "Open Media Gallery", group: "Workspace", aliases: "media gallery assets diagrams pdfs images video audio mermaid drawio excalidraw" }, - { id: "open-attached-repos", label: "Open Attached Code Repositories", group: "Workspace", aliases: "git repos code ast symbols attach repositories graphify" }, + { id: "open-workspace-diagrams-media", label: "Open Media Gallery", group: "Workspace", shortcut: "Ctrl/Cmd+Alt+M", aliases: "media gallery assets diagrams pdfs images video audio mermaid drawio excalidraw" }, + { id: "open-attached-repos", label: "Open Attached Code Repositories", group: "Workspace", shortcut: "Ctrl/Cmd+Alt+G", aliases: "git repos code ast symbols attach repositories graphify" }, + { id: "open-downloads-page", label: "Open Downloads & Export History", group: "App", shortcut: "Ctrl/Cmd+J", aliases: "downloads exports export history pdf packages" }, + { id: "open-git-version-control", label: "Open Revisions & Sync", group: "Tools", shortcut: "Ctrl/Cmd+Shift+G", aliases: "git version control vc source commit branch diff history milestone sync" }, { id: "open-workspace-activity", label: "Open Workspace Activity", group: "Sync", aliases: "activity timeline sync events" }, - { id: "open-p2p-status", label: "Open P2P Status", group: "Sync", aliases: "peer status p2p" }, + { id: "open-p2p-status", label: "Open P2P Settings", group: "Sync", aliases: "peer status p2p sync settings" }, { id: "open-knowledge-graph", label: "Open Knowledge Graph", group: "AI", aliases: "workspace graph mind map network relations nodes" }, { id: "open-embeddings-page", label: "Open Embeddings Dashboard", group: "AI", aliases: "vector database indexing onnx local bge segments" }, { id: "open-ai-settings", label: "Open AI Settings", group: "AI", aliases: "llm ai config" }, { id: "open-mcp-settings", label: "Open MCP Server Settings", group: "AI", aliases: "mcp server tools model context protocol" }, - { id: "open-mcp-diagnostics", label: "Open MCP Diagnostics", group: "AI", aliases: "mcp health telemetry diagnostics status" }, + { id: "open-diagnostics", label: "Open Diagnostics", group: "AI", aliases: "mcp health telemetry diagnostics status ai health logs" }, + { id: "open-app-logs", label: "Open System & Application Logs", group: "Help", aliases: "logs system application telemetry console error debug" }, + { id: "open-mcp-tools", label: "Open MCP Tools & Capabilities", group: "AI", aliases: "mcp tools agent external server api" }, { id: "toggle-terminal", label: showTerminal ? "Hide Terminal" : "Show Terminal", group: "View", aliases: "console shell" }, { id: "toggle-view-mode", @@ -2849,34 +2938,76 @@ export default function App() { } if (resolvedCommandId === "open-downloads-page" || resolvedCommandId === "open-downloads") { + closeAllFullscreenViews(); setDownloadsPageOpen(true); return; } + if (resolvedCommandId === "open-git-version-control") { + closeAllFullscreenViews(); + setGitVCInitialTab("status"); + setGitVCOpen(true); + return; + } + + if (resolvedCommandId === "open-workspace-index" || resolvedCommandId === "workspace-index") { + closeAllFullscreenViews(); + setWorkspaceIndexOpen(true); + return; + } + + if (resolvedCommandId === "open-app-logs") { + closeAllFullscreenViews(); + setAppLogsOpen(true); + return; + } + + if (resolvedCommandId === "open-mcp-tools") { + closeAllFullscreenViews(); + setMcpToolsPageOpen(true); + return; + } + if (resolvedCommandId === "open-recent-workspaces") { setRecentWorkspacesDialogOpen(true); return; } + if (resolvedCommandId === "open-settings") { + openSettings("general"); + return; + } - if (resolvedCommandId === "open-workspace-graph") { + if (resolvedCommandId === "open-knowledge-graph" || resolvedCommandId === "open-workspace-graph") { + closeAllFullscreenViews(); setGraphPanelOpen(true); return; } if (resolvedCommandId === "open-embeddings-page") { + closeAllFullscreenViews(); setEmbeddingsPageOpen(true); return; } - - - if (resolvedCommandId === "open-tasks-panel" || resolvedCommandId === "open-all-tasks") { + if (resolvedCommandId === "open-tasks-workspace" || resolvedCommandId === "open-tasks-panel" || resolvedCommandId === "open-all-tasks") { + closeAllFullscreenViews(); setTaskWorkspaceContext(null); setTaskWorkspaceOpen(true); return; } + if (resolvedCommandId === "open-calendar") { + closeAllFullscreenViews(); + setCalendarPageOpen(true); + return; + } + + if (resolvedCommandId === "open-trash") { + setTrashDialogOpen(true); + return; + } + if (resolvedCommandId === "open-global-search") { setGlobalSearchQuery(""); setGlobalSearchOpen(true); @@ -2889,11 +3020,13 @@ export default function App() { } if (resolvedCommandId === "open-workspace-diagrams-media" || resolvedCommandId === "open-assets") { + closeAllFullscreenViews(); setDiagramsMediaOpen(true); return; } if (resolvedCommandId === "open-attached-repos" || resolvedCommandId === "open-git-repos") { + closeAllFullscreenViews(); setAttachedReposPageOpen(true); return; } @@ -2904,27 +3037,22 @@ export default function App() { } if (resolvedCommandId === "open-p2p-status") { - await handleOpenP2PStatus(); - return; - } - - if (resolvedCommandId === "open-knowledge-graph") { - setGraphPanelOpen(true); + openSettings("p2p"); return; } if (resolvedCommandId === "open-ai-settings") { - setAiSettingsOpen(true); + openSettings("ai"); return; } if (resolvedCommandId === "open-mcp-settings") { - setSettingsTab("mcp"); - setSettingsOpen(true); + openSettings("mcp"); return; } - if (resolvedCommandId === "open-mcp-diagnostics") { + if (resolvedCommandId === "open-diagnostics" || resolvedCommandId === "open-mcp-diagnostics" || resolvedCommandId === "open-ai-health") { + closeAllFullscreenViews(); setHealthPageOpen(true); return; } @@ -3046,26 +3174,36 @@ export default function App() { function handleDashboardAction(action) { if (action === "tasks-workspace" || action === "tasks-overdue") { + closeAllFullscreenViews(); setTaskWorkspaceContext(null); setTaskWorkspaceOpen(true); return; } if (action === "calendar") { + closeAllFullscreenViews(); setCalendarPageOpen(true); return; } if (action === "workspace-index" || action === "open-workspace-index") { + closeAllFullscreenViews(); setWorkspaceIndexOpen(true); return; } - if (action === "workspace-diagrams-media" || action === "open-workspace-diagrams-media") { + if (action === "workspace-diagrams-media" || action === "open-workspace-diagrams-media" || action === "assets") { + closeAllFullscreenViews(); setDiagramsMediaOpen(true); return; } + if (action === "attached-repos" || action === "open-attached-repos") { + closeAllFullscreenViews(); + setAttachedReposPageOpen(true); + return; + } + if (action === "new-note") { setNoteDialogOpen(true); return; @@ -3087,16 +3225,6 @@ export default function App() { return; } - if (action === "assets") { - setDiagramsMediaOpen(true); - return; - } - - if (action === "attached-repos" || action === "open-attached-repos") { - setAttachedReposPageOpen(true); - return; - } - if (action === "trash") { setTrashDialogOpen(true); } @@ -3275,15 +3403,25 @@ export default function App() { isRepo: gitWorkspaceMeta.isGitRoot, branch: gitWorkspaceMeta.branch, pendingCount: gitWorkspaceMeta.pendingCount || 0, + ahead: gitWorkspaceMeta.ahead || 0, + behind: gitWorkspaceMeta.behind || 0, loading: false, }} onClick={() => setGitVCOpen(true)} + onOpenVC={(tab) => { + if (tab) setGitVCInitialTab(tab); + setGitVCOpen(true); + }} + onSaveMilestone={() => { + setGitVCInitialTab("changes"); + setGitVCOpen(true); + }} + onSync={handleQuickGitSync} + /> + openSettings("ai")} + onOpenMcpSettings={() => openSettings("mcp")} /> - setAiSettingsOpen(true)} /> - { - setSettingsTab("mcp"); - setSettingsOpen(true); - }} /> {current && !(graphPanelOpen || embeddingsPageOpen || healthPageOpen || appLogsOpen || gitVCOpen) ? ( <> {documentStats ? ( @@ -3418,7 +3556,7 @@ export default function App() { await handleLandingNavigateTo(targetPath); }} - onOpenAISettings={() => setAiSettingsOpen(true)} + onOpenAISettings={() => openSettings("ai")} onOpenDocument={handleOpenReferencedDocumentFromUI} initialLine={initialLine} onLineJumped={() => setInitialLine(null)} diff --git a/src/components/AIHealthPage.jsx b/src/components/AIHealthPage.jsx index 44f54bc8..12b7a5f3 100644 --- a/src/components/AIHealthPage.jsx +++ b/src/components/AIHealthPage.jsx @@ -27,6 +27,9 @@ import { onMcpStatusChanged } from '../services/electronService'; import { useConfirm } from '../hooks/useConfirm'; +import AppButton from './AppButton'; +import SubpageHeader from './layout/SubpageHeader'; +import { showSuccessToast, showInfoToast, showErrorToast } from '../utils/notificationUtils'; import '../styles/KnowledgeGraph.css'; import '../styles/AISettings.css'; import '../styles/AIHealthPage.css'; @@ -43,11 +46,14 @@ function copyToClipboard(text, label) { try { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { navigator.clipboard.writeText(text); + showSuccessToast(`${label} copied to clipboard`); + } else { + showErrorToast(`Unable to copy ${label} to clipboard`); } } catch (err) { console.warn('Clipboard write failed:', err); + showErrorToast(`Unable to copy ${label} to clipboard`); } - window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: `${label} copied to clipboard`, type: 'success' } })); } function formatJson(val) { @@ -199,9 +205,10 @@ export default function MCPDiagnosticsPage({ onBack }) { try { await aiClearLogs(); await fetchData(); - window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: 'MCP Telemetry logs cleared', type: 'info' } })); + showInfoToast('MCP Telemetry logs cleared'); } catch (err) { console.error('Clear logs failed:', err); + showErrorToast(`Clear logs failed: ${err?.message || 'Unknown error'}`); } } }; @@ -243,25 +250,40 @@ export default function MCPDiagnosticsPage({ onBack }) { return (
- {/* Unified topbar navigation breadcrumb */} -
- -
+ + + + Refresh + + + + + Clear Logs + +
+ } + /> {/* Header Actions Bar — matching Knowledge Graph & Embeddings page */}
{/* Sleek Running Status Pill */} -
+
{activeConnCount}
- -
- - - -
{/* Main Two-Column Body */} @@ -334,7 +334,7 @@ export default function MCPDiagnosticsPage({ onBack }) { justifyContent: 'center', cursor: 'pointer', color: 'var(--text-muted)', - borderRadius: '4px' + borderRadius: 'var(--radius-default)' }} onClick={() => handleCopyCode(`http://${serverHost}:${serverPort}/mcp`, 'endpoint_http', 'MCP Endpoint URL')} title={copiedId === 'endpoint_http' ? 'Copied to clipboard' : 'Copy MCP Endpoint URL'} @@ -361,7 +361,7 @@ export default function MCPDiagnosticsPage({ onBack }) { justifyContent: 'center', cursor: 'pointer', color: 'var(--text-muted)', - borderRadius: '4px' + borderRadius: 'var(--radius-default)' }} onClick={() => handleCopyCode(`http://${serverHost}:${serverPort}/sse`, 'endpoint_sse', 'SSE Endpoint URL')} title={copiedId === 'endpoint_sse' ? 'Copied to clipboard' : 'Copy SSE Endpoint URL'} diff --git a/src/components/AISettings.jsx b/src/components/AISettings.jsx index 1e77141b..7dd882de 100644 --- a/src/components/AISettings.jsx +++ b/src/components/AISettings.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Save, Trash2, Zap, AlertCircle, Eye, EyeOff, Download, Database, Mic, Cpu, Network } from 'lucide-react'; import AppInput from './AppInput'; import AppSelect from './AppSelect'; +import AppButton from './AppButton'; import "../styles/AISettings.css"; import OverlayDialog from './OverlayDialog'; import KnowledgeGraphSettings from './KnowledgeGraphSettings'; @@ -16,6 +17,7 @@ import { showSuccessToast, showErrorToast, showInfoToast, + showWarningToast, } from '../utils/notificationUtils'; import { aiClearData, @@ -214,9 +216,7 @@ export const AISettingsContent = ({ _onClose }) => { const handleSaveAPIKey = async () => { const keyToSave = showPlaintext ? plaintextKey : apiKey; if (!keyToSave || keyToSave.includes('...')) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Please enter a complete API key.', type: 'warning' } - })); + showWarningToast('Please enter a complete API key.'); return; } @@ -225,20 +225,14 @@ export const AISettingsContent = ({ _onClose }) => { const response = await aiSetApiKey(selectedProvider, keyToSave); if (response.success) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `${selectedProvider} API key saved successfully.`, type: 'success' } - })); + showSuccessToast(`${selectedProvider} API key saved successfully.`); setApiKey(keyToSave.substring(0, 5) + '...' + keyToSave.substring(keyToSave.length - 5)); setPlaintextKey(keyToSave); } else { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Failed to save key: ${response.error}`, type: 'error' } - })); + showErrorToast(`Failed to save key: ${response.error}`); } } catch (error) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Error saving key: ${error.message}`, type: 'error' } - })); + showErrorToast(`Error saving key: ${error.message}`); } finally { setLoading(false); } @@ -247,9 +241,7 @@ export const AISettingsContent = ({ _onClose }) => { const handleSaveHfToken = async () => { const tokenToSave = showHfPlaintext ? hfPlaintextToken : hfToken; if (!tokenToSave || tokenToSave.includes('...')) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Please enter a complete HuggingFace token.', type: 'warning' } - })); + showWarningToast('Please enter a complete HuggingFace token.'); return; } @@ -257,21 +249,15 @@ export const AISettingsContent = ({ _onClose }) => { setLoading(true); const response = await aiSetApiKey('huggingface', tokenToSave); if (response.success) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'HuggingFace token saved successfully.', type: 'success' } - })); + showSuccessToast('HuggingFace token saved successfully.'); setHfToken(tokenToSave.substring(0, 5) + '...' + tokenToSave.substring(tokenToSave.length - 5)); setHfPlaintextToken(tokenToSave); setHfConfigured(true); } else { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Failed to save HuggingFace token: ${response.error}`, type: 'error' } - })); + showErrorToast(`Failed to save HuggingFace token: ${response.error}`); } } catch (error) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Error: ${error.message}`, type: 'error' } - })); + showErrorToast(`Error: ${error.message}`); } finally { setLoading(false); } @@ -289,20 +275,14 @@ export const AISettingsContent = ({ _onClose }) => { setTestResult(res); if (res.success) { setStatus(`Connection to ${selectedProvider} successful!`); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Connected to ${selectedProvider} successfully!`, type: 'success' } - })); + showSuccessToast(`Connected to ${selectedProvider} successfully!`); } else { setStatus(`Connection failed: ${res.error}`); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Connection failed: ${res.error}`, type: 'error' } - })); + showErrorToast(`Connection failed: ${res.error}`); } } catch (err) { setStatus(`Test failed: ${err.message}`); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Error: ${err.message}`, type: 'error' } - })); + showErrorToast(`Error: ${err.message}`); } finally { setLoading(false); } @@ -315,17 +295,14 @@ export const AISettingsContent = ({ _onClose }) => { const res = await aiTestConnection({ provider: 'huggingface' }); if (res.success) { setStatus('HuggingFace connection successful!'); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'HuggingFace embeddings connected successfully!', type: 'success' } - })); + showSuccessToast('HuggingFace embeddings connected successfully!'); } else { setStatus(`HuggingFace connection failed: ${res.error}`); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `HuggingFace connection failed: ${res.error}`, type: 'error' } - })); + showErrorToast(`HuggingFace connection failed: ${res.error}`); } } catch (err) { setStatus(`HuggingFace test failed: ${err.message}`); + showErrorToast(`HuggingFace test failed: ${err.message}`); } finally { setLoading(false); } @@ -339,18 +316,12 @@ export const AISettingsContent = ({ _onClose }) => { setLoading(true); const res = await aiClearData(); if (res.success) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'AI local data cleared successfully.', type: 'success' } - })); + showSuccessToast('AI local data cleared successfully.'); } else { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Failed to clear data: ${res.error}`, type: 'error' } - })); + showErrorToast(`Failed to clear data: ${res.error}`); } } catch (err) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Error clearing data: ${err.message}`, type: 'error' } - })); + showErrorToast(`Error clearing data: ${err.message}`); } finally { setLoading(false); } @@ -397,7 +368,7 @@ export const AISettingsContent = ({ _onClose }) => { justifyContent: "space-between", alignItems: "center", padding: "12px 16px", - borderRadius: "8px", + borderRadius: "var(--radius-default)", border: "1px solid var(--border-soft)", background: "var(--background-soft)", marginBottom: "16px" @@ -422,13 +393,9 @@ export const AISettingsContent = ({ _onClose }) => { await aiDisable(); } await aiSetPreferences(nextPrefs); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `AI Subsystem ${checked ? 'enabled' : 'disabled'}.`, type: 'success' } - })); + showSuccessToast(`AI Subsystem ${checked ? 'enabled' : 'disabled'}.`); } catch (err) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Failed to toggle AI: ${err.message}`, type: 'error' } - })); + showErrorToast(`Failed to toggle AI: ${err.message}`); } }} style={{ opacity: 0, width: 0, height: 0 }} @@ -437,7 +404,7 @@ export const AISettingsContent = ({ _onClose }) => { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, background: isAIEnabled ? "var(--accent-solid)" : "var(--border-default)", - borderRadius: "20px", + borderRadius: "9999px", transition: "background var(--motion-standard)", cursor: "pointer" }}> @@ -520,11 +487,10 @@ export const AISettingsContent = ({ _onClose }) => { setPreferences(updatedPrefs); try { await aiSetPreferences(updatedPrefs); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Active provider set to ${val}.`, type: 'success' } - })); + showSuccessToast(`Active provider set to ${val}.`); } catch (err) { console.error('Failed to set active provider:', err); + showErrorToast(`Failed to set active provider: ${err?.message || 'Unknown error'}`); } }} disabled={loading} @@ -613,22 +579,20 @@ export const AISettingsContent = ({ _onClose }) => { ); })()} - - + {/* Provider-specific details and helper links */} @@ -648,7 +612,7 @@ export const AISettingsContent = ({ _onClose }) => { {getCapabilityWarnings().length > 0 && (
{getCapabilityWarnings().map((warning, idx) => ( -
+
{warning.title} @@ -706,7 +670,7 @@ export const AISettingsContent = ({ _onClose }) => {
-
+
Local AI Memory & Cache Data stored in .notes-app/ai-memory.db @@ -745,8 +709,8 @@ export const AISettingsContent = ({ _onClose }) => { - +
{preferences.embeddingProvider === 'huggingface' && ( -
+

Uses HuggingFace Inference API free tier. Get a token at huggingface.co.

@@ -843,28 +800,26 @@ export const AISettingsContent = ({ _onClose }) => { {showHfPlaintext ? : }
- - +
)} {((preferences.embeddingProvider || 'internal') === 'internal' || !modelStatus.downloaded) && ( -
+

Local Model Status (BGE ONNX)

{modelStatus.downloaded ? (
@@ -880,11 +835,10 @@ export const AISettingsContent = ({ _onClose }) => { setLoading(true); await aiDeleteModel(); setModelStatus({ downloaded: false, isDownloading: false, progress: 0 }); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Local BGE embedding model weights deleted.', type: 'info' } - })); + showInfoToast('Local BGE embedding model weights deleted.'); } catch (err) { console.error(err); + showErrorToast(`Failed to delete model: ${err?.message || 'Unknown error'}`); } finally { setLoading(false); } @@ -903,7 +857,7 @@ export const AISettingsContent = ({ _onClose }) => { Downloading local weights... {modelStatus.progress}%
-
+
@@ -987,7 +941,7 @@ export const AISettingsContent = ({ _onClose }) => {
{ {whisperModelStatus.statusText || 'Downloading ONNX model weights...'} {whisperModelStatus.progress}%
-
+
@@ -1121,9 +1075,8 @@ export const AISettingsContent = ({ _onClose }) => {
- +
diff --git a/src/components/AIStatusBar.jsx b/src/components/AIStatusBar.jsx index 83f1b41a..19826936 100644 --- a/src/components/AIStatusBar.jsx +++ b/src/components/AIStatusBar.jsx @@ -1,31 +1,152 @@ -import React, { useEffect, useState } from "react"; -import { Sparkles, Activity, AlertTriangle, CheckCircle, Pause } from "lucide-react"; -import { aiGetHealth, aiGetPreferences } from "../services/electronService"; +import React, { useEffect, useState, useRef } from "react"; +import { + Sparkles, + Activity, + AlertTriangle, + CheckCircle, + Pause, + Cpu, + Layers, + Network, + Mic, + Server, + Cloud, + ShieldAlert, + ChevronRight, + X, + ExternalLink +} from "lucide-react"; +import { + aiGetHealth, + aiGetPreferences, + aiGetModelStatus, + aiGetGraphModelStatus, + aiGetProviderList, + mcpGetStatus, + mcpGetSessions, + onMcpStatusChanged +} from "../services/electronService"; +import { + getSTTPreferences, + checkLocalWhisperModelStatus +} from "../services/sttService"; +import "../styles/AIStatusBar.css"; -export function AIStatusBar({ onClick }) { - const [status, setStatus] = useState("disabled"); // disabled, idle, indexing, error - const [provider, setProvider] = useState(""); +export function AIStatusBar({ onClick, onOpenMcpSettings }) { + const [status, setStatus] = useState("disabled"); // disabled, idle, indexing, error, low_ram + const [providerLabel, setProviderLabel] = useState("Local"); + const [isOpen, setIsOpen] = useState(false); + const hoverTimerRef = useRef(null); + const [modelsData, setModelsData] = useState({ + embModel: "all-MiniLM-L6-v2", + embDownloaded: false, + embIsLoaded: false, + graphModel: "GLiNER2 NER", + graphDownloaded: false, + graphIsLoaded: false, + sttEngine: "local-onnx", + sttModel: "whisper-tiny.en", + sttDownloaded: false, + cloudProvider: null, + cloudModel: null, + mcpRunning: true, + mcpPort: 3700, + mcpError: null, + mcpSessionsCount: 0, + mcpToolsCount: 22, + systemMemory: { freeMemMb: 0, totalMemMb: 0, isLowMemory: false }, + database: { totalChunks: 0, indexedNotes: 0, totalEntities: 0, totalRelations: 0 }, + mcp: { totalSessions: 0, totalToolCalls: 0 } + }); const updateStatus = async () => { try { - const prefs = await aiGetPreferences(); + const prefsRes = await aiGetPreferences(); + const prefs = prefsRes?.preferences || prefsRes; const aiEnabled = prefs?.aiEnabled !== false; if (!aiEnabled) { setStatus("disabled"); - setProvider(""); + setProviderLabel("Disabled"); return; } - const health = await aiGetHealth(); - if (health?.success && health.data) { - const rawProv = health.data.activeProvider || "Unknown"; - const providerName = rawProv.charAt(0).toUpperCase() + rawProv.slice(1); - setProvider(providerName); - if (health.data.isIndexing) { + const [healthRes, embRes, graphRes, sttPrefs, provListRes, mcpStatusRes, mcpSessionsRes] = await Promise.allSettled([ + aiGetHealth(), + aiGetModelStatus(), + aiGetGraphModelStatus(), + getSTTPreferences(), + aiGetProviderList(), + mcpGetStatus(), + mcpGetSessions() + ]); + + const healthData = healthRes.status === "fulfilled" && healthRes.value?.success ? healthRes.value.data : null; + const embData = embRes.status === "fulfilled" && embRes.value?.success ? embRes.value.data : null; + const graphData = graphRes.status === "fulfilled" && graphRes.value?.success ? graphRes.value.data : null; + const sttData = sttPrefs.status === "fulfilled" ? sttPrefs.value : { engine: "local-onnx", localModel: "onnx-community/whisper-tiny.en" }; + const provList = provListRes.status === "fulfilled" && provListRes.value?.success ? provListRes.value.data : []; + const mcpStatus = mcpStatusRes.status === "fulfilled" && mcpStatusRes.value?.success ? mcpStatusRes.value.data : null; + const mcpSessions = mcpSessionsRes.status === "fulfilled" && mcpSessionsRes.value?.success ? mcpSessionsRes.value.data : []; + + let isSttDownloaded = false; + if (sttData.engine === "local-onnx") { + try { + const sttStatus = await checkLocalWhisperModelStatus(sttData.localModel); + isSttDownloaded = Boolean(sttStatus?.downloaded); + } catch { + // ignore + } + } + + let activeCloud = null; + if (Array.isArray(provList)) { + const configured = provList.find(p => p.hasApiKey || p.configured || p.active); + if (configured) { + activeCloud = { + provider: configured.name || configured.id, + model: configured.selectedModel || configured.defaultModel || "Active" + }; + } + } + + const rawProv = healthData?.activeProvider || ""; + const displayProv = activeCloud + ? activeCloud.provider.charAt(0).toUpperCase() + activeCloud.provider.slice(1) + : (rawProv && rawProv !== "Unknown" ? rawProv : "Local"); + setProviderLabel(displayProv); + + const isLowMem = Boolean(healthData?.systemMemory?.isLowMemory); + + if (healthData) { + setModelsData({ + embModel: prefs?.embeddingModel || "all-MiniLM-L6-v2", + embDownloaded: Boolean(embData?.downloaded), + embIsLoaded: Boolean(embData?.isLoaded), + graphModel: "GLiNER2 NER", + graphDownloaded: Boolean(graphData?.downloaded), + graphIsLoaded: Boolean(graphData?.isLoaded), + sttEngine: sttData.engine || "local-onnx", + sttModel: sttData.engine === "local-onnx" ? (sttData.localModel?.split("/").pop() || "whisper-tiny.en") : sttData.engine, + sttDownloaded: isSttDownloaded, + cloudProvider: activeCloud?.provider || null, + cloudModel: activeCloud?.model || null, + mcpRunning: mcpStatus?.running !== false, + mcpPort: mcpStatus?.port || 3700, + mcpError: mcpStatus?.error || null, + mcpSessionsCount: Array.isArray(mcpSessions) ? mcpSessions.length : (mcpStatus?.activeSessions || 0), + mcpToolsCount: mcpStatus?.toolCount || 22, + systemMemory: healthData.systemMemory || { freeMemMb: 0, totalMemMb: 0, isLowMemory: false }, + database: healthData.database || { totalChunks: 0, indexedNotes: 0, totalEntities: 0, totalRelations: 0 }, + mcp: healthData.mcp || { totalSessions: 0, totalToolCalls: 0 } + }); + + if (healthData.isIndexing) { setStatus("indexing"); - } else if (health.data.isPaused) { + } else if (healthData.isPaused) { setStatus("paused"); + } else if (isLowMem) { + setStatus("low_ram"); } else { setStatus("idle"); } @@ -39,18 +160,44 @@ export function AIStatusBar({ onClick }) { useEffect(() => { updateStatus(); + const unsubMcp = onMcpStatusChanged?.(() => updateStatus()); const interval = setInterval(updateStatus, 5000); - return () => clearInterval(interval); + return () => { + unsubMcp?.(); + clearInterval(interval); + }; }, []); + const handleMouseEnter = () => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + updateStatus(); + setIsOpen(true); + }; + + const handleMouseLeave = () => { + hoverTimerRef.current = setTimeout(() => { + setIsOpen(false); + }, 250); + }; + + const handleClose = (e) => { + e?.stopPropagation?.(); + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + setIsOpen(false); + }; + let Icon = Sparkles; let label = "AI: Not Ready"; let statusClass = "ai-status-bar--disabled"; if (status === "idle") { Icon = CheckCircle; - label = `AI: Ready (${provider})`; + label = `AI: Ready (${providerLabel})`; statusClass = "ai-status-bar--ready"; + } else if (status === "low_ram") { + Icon = ShieldAlert; + label = `AI: Standby (Low RAM)`; + statusClass = "ai-status-bar--low-ram"; } else if (status === "indexing") { Icon = Activity; label = "AI: Indexing..."; @@ -65,17 +212,198 @@ export function AIStatusBar({ onClick }) { statusClass = "ai-status-bar--error"; } + const isLowMem = modelsData.systemMemory?.isLowMemory; + const isMcpError = Boolean(modelsData.mcpError); + return ( - + {isOpen && ( +
e.stopPropagation()} + > + {/* Header */} +
+ AI & Subsystems +
+ + {status === "low_ram" ? "Low RAM" : status === "idle" ? "Ready" : status} + + +
+
+ + {/* List items */} +
+ {/* 1. Embeddings */} +
+
+ +
+
+
+ Embeddings + + {modelsData.embIsLoaded + ? "In RAM" + : modelsData.embDownloaded + ? "Idle (Cached)" + : "Standby"} + +
+
+ {modelsData.embModel} · {modelsData.database.totalChunks} chunks ({modelsData.database.indexedNotes} notes) +
+
+
+ + {/* 2. Knowledge Graph */} +
+
+ +
+
+
+ Knowledge Graph + + {isLowMem + ? "Rule Engine" + : modelsData.graphIsLoaded + ? "In RAM" + : modelsData.graphDownloaded + ? "Idle (Cached)" + : "Standby"} + +
+
+ {modelsData.graphModel} · {modelsData.database.totalEntities} entities, {modelsData.database.totalRelations} links +
+
+
+ + {/* 3. Audio / Whisper */} +
+
+ +
+
+
+ Voice & Audio + + {modelsData.sttEngine === "local-onnx" + ? (modelsData.sttDownloaded ? "Idle (Cached)" : "On-Demand") + : `${modelsData.sttEngine.toUpperCase()} Cloud`} + +
+
+ {modelsData.sttModel} · {modelsData.sttEngine === "local-onnx" ? "Whisper ONNX" : "Cloud Transcription"} +
+
+
+ + {/* 4. Local MCP Server */} +
{ + handleClose(); + if (typeof onOpenMcpSettings === "function") onOpenMcpSettings(); + else if (typeof onClick === "function") onClick(); + }} + role="button" + tabIndex={0} + > +
+ +
+
+
+ Local MCP Server + + :{modelsData.mcpPort} + +
+
+ {modelsData.mcpRunning ? "Running" : "Stopped"} · {modelsData.mcpToolsCount} tools, {modelsData.mcpSessionsCount} client{modelsData.mcpSessionsCount !== 1 ? "s" : ""} + +
+
+
+ + {/* 5. Cloud LLM (if configured) */} + {modelsData.cloudProvider && ( +
+
+ +
+
+
+ Cloud LLM + Connected +
+
+ {modelsData.cloudProvider} · {modelsData.cloudModel} +
+
+
+ )} + + {/* 6. System RAM */} +
+
+ +
+
+
+ System Memory + + {isLowMem ? "Low RAM" : "Optimal"} + +
+
+ {modelsData.systemMemory.freeMemMb} MB free / {modelsData.systemMemory.totalMemMb} MB total +
+
+
+
+ + {/* Footer link */} + +
+ )} + + +
); } diff --git a/src/components/AppButton.jsx b/src/components/AppButton.jsx index 16b6b2c5..bd6573f8 100644 --- a/src/components/AppButton.jsx +++ b/src/components/AppButton.jsx @@ -13,10 +13,21 @@ export const AppButton = forwardRef(function AppButton( }, ref, ) { - const baseClass = variant === "primary" ? "primary-button" : "small-button"; + let baseClass = "small-button"; + if (variant === "primary") { + baseClass = "primary-button"; + } else if (variant === "secondary") { + baseClass = "secondary-button small-button"; + } else if (variant === "ghost" || variant === "text") { + baseClass = "text-button ghost-button"; + } else if (variant === "danger") { + baseClass = "danger-button small-button danger"; + } + + const isDanger = danger || variant === "danger"; const classes = [ baseClass, - danger ? "danger" : "", + isDanger ? "danger" : "", iconOnly ? "icon-only" : "", className, ] diff --git a/src/components/AppLogsPage.jsx b/src/components/AppLogsPage.jsx index 4d91d4af..2ec1b57c 100644 --- a/src/components/AppLogsPage.jsx +++ b/src/components/AppLogsPage.jsx @@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { Search, RefreshCw, Trash2, Download, Terminal, Filter } from 'lucide-react'; import { aiGetLogs, aiClearLogs } from '../services/electronService'; import useConfirm from '../hooks/useConfirm'; +import AppButton from './AppButton'; +import SubpageHeader from './layout/SubpageHeader'; import '../styles/KnowledgeGraph.css'; @@ -81,20 +83,48 @@ export default function AppLogsPage({ onBack }) { return (
- {/* Top Navigation Breadcrumb */} -
- -
+ + setAutoRefresh(!autoRefresh)} + style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }} + > + + {autoRefresh ? 'Live Auto-Refresh' : 'Paused'} + + + + + Export + + + + + Clear + +
+ } + />
{/* Controls Toolbar */} -
+
@@ -113,7 +143,7 @@ export default function AppLogsPage({ onBack }) { setLevelFilter(e.target.value)} - style={{ height: '32px', boxSizing: 'border-box', background: 'var(--surface-bg)', color: 'var(--text-strong)', border: '1px solid var(--border-default)', borderRadius: '6px', padding: '0 10px', fontSize: '12px', outline: 'none', cursor: 'pointer' }} + style={{ height: '32px', boxSizing: 'border-box', background: 'var(--surface-bg)', color: 'var(--text-strong)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-default)', padding: '0 10px', fontSize: '12px', outline: 'none', cursor: 'pointer' }} > @@ -135,50 +165,21 @@ export default function AppLogsPage({ onBack }) {
- -
- - - - - -
{/* Logs Console Container */} -
+
{filteredLogs.length > 0 ? ( filteredLogs.map((item, idx) => { const isErr = item.level === 'error'; const isWarn = item.level === 'warn'; const levelColor = isErr ? 'var(--text-danger)' : isWarn ? 'var(--text-warning)' : 'var(--status-success-text)'; return ( -
+
[{new Date(item.timestamp).toLocaleTimeString()}] - + {item.subsystem || 'app'} diff --git a/src/components/AttachedReposPage.jsx b/src/components/AttachedReposPage.jsx index 63cc9d19..482c7ddc 100644 --- a/src/components/AttachedReposPage.jsx +++ b/src/components/AttachedReposPage.jsx @@ -2,21 +2,10 @@ import { useState, useMemo, useEffect, useCallback } from "react"; import { FolderGit2, GitBranch, - GitCommit, Search, Plus, RefreshCw, - Trash2, - ExternalLink, - Share2, - FileText, - X, - Copy, - Check, - PanelLeftClose, - PanelLeftOpen, - FolderOpen, - Layers + X } from "lucide-react"; import { getAttachedRepos, @@ -25,13 +14,14 @@ import { onAttachedReposChanged, openFolder, openExternal, - aiBuildGraph, - onGraphProgress + aiBuildGraph } from "../services/electronService"; import AppButton from "./AppButton"; -import AppIconButton from "./AppIconButton"; -import AppInput from "./AppInput"; -import OverlayDialog from "./OverlayDialog"; +import SubpageHeader from "./layout/SubpageHeader"; +import { + RepoCard, + AttachRepoModal +} from "./repos"; import { showToast } from "../utils/notificationUtils"; import "../styles/AttachedReposPage.css"; @@ -40,25 +30,13 @@ export function AttachedReposPage({ documents = [], onBack, onClose, - onNotify, - onOpenKnowledgeGraph, - onOpenNote + onNotify }) { const handleBack = onBack || onClose; const [repos, setRepos] = useState([]); const [searchQuery, setSearchQuery] = useState(""); - const [selectedRepoId, setSelectedRepoId] = useState("all"); - const [selectedItem, setSelectedItem] = useState(null); - const [sidebarOpen, setSidebarOpen] = useState(true); const [isScanning, setIsScanning] = useState(false); - const [scanProgress, setScanProgress] = useState(null); - const [copiedLink, setCopiedLink] = useState(false); - - // Attach Modal State const [attachModalOpen, setAttachModalOpen] = useState(false); - const [newRepoPath, setNewRepoPath] = useState(""); - const [newRepoName, setNewRepoName] = useState(""); - const [newRepoBranch, setNewRepoBranch] = useState("main"); // Escape key handler useEffect(() => { @@ -66,8 +44,6 @@ export function AttachedReposPage({ if (e.key === "Escape") { if (attachModalOpen) { setAttachModalOpen(false); - } else if (selectedItem) { - setSelectedItem(null); } else if (typeof handleBack === "function") { handleBack(); } @@ -75,7 +51,7 @@ export function AttachedReposPage({ }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [attachModalOpen, selectedItem, handleBack]); + }, [attachModalOpen, handleBack]); const notifyUser = useCallback((msg, type = "info") => { if (typeof onNotify === "function") { @@ -105,57 +81,12 @@ export function AttachedReposPage({ }; }, [loadRepos]); - // Browse Directory for new repo - const handleBrowseRepoFolder = async () => { - try { - if (window.notesApi?.showOpenDialog) { - const result = await window.notesApi.showOpenDialog({ - title: "Select Git Repository Directory", - properties: ["openDirectory"] - }); - if (result && !result.canceled && result.filePaths?.length > 0) { - const selected = result.filePaths[0]; - setNewRepoPath(selected); - const baseName = selected.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "repository"; - if (!newRepoName) setNewRepoName(baseName); - } - } - } catch (err) { - notifyUser("Error opening folder picker: " + err.message, "error"); - } - }; - - // Submit Attach Repo - const handleAttachSubmit = async (e) => { - if (e) e.preventDefault(); - if (!newRepoPath.trim()) { - notifyUser("Repository path is required", "error"); - return; - } - try { - await addAttachedRepo({ - path: newRepoPath.trim(), - name: newRepoName.trim() || newRepoPath.split(/[\\/]/).pop(), - branch: newRepoBranch.trim() || "main" - }); - notifyUser("Repository attached successfully", "success"); - setAttachModalOpen(false); - setNewRepoPath(""); - setNewRepoName(""); - loadRepos(); - } catch (err) { - notifyUser("Failed attaching repository: " + err.message, "error"); - } - }; - // Detach Repo const handleDetachRepo = async (repoId, repoName) => { if (window.confirm(`Detach repository "${repoName}" from workspace?`)) { try { await removeAttachedRepo(repoId); notifyUser("Repository detached", "info"); - if (selectedRepoId === repoId) setSelectedRepoId("all"); - if (selectedItem?.id === repoId) setSelectedItem(null); loadRepos(); } catch (err) { notifyUser("Failed to detach repository: " + err.message, "error"); @@ -167,29 +98,38 @@ export function AttachedReposPage({ const handleRescanGraph = async () => { if (isScanning) return; setIsScanning(true); - setScanProgress("Starting AST scan..."); try { - const unsub = onGraphProgress((data) => { - if (data?.noteName) { - setScanProgress(`Processing: ${data.noteName} (${data.current}/${data.total})`); - } - }); await aiBuildGraph(); - if (typeof unsub === "function") unsub(); - notifyUser("Code & Notes Knowledge Graph updated!", "success"); + notifyUser("Knowledge Graph updated", "success"); loadRepos(); } catch (err) { notifyUser("Scan failed: " + err.message, "error"); } finally { setIsScanning(false); - setScanProgress(null); + } + }; + + // Submit Attach Repo + const handleAttachSubmit = async ({ path: repoPath, name: repoName, branch: repoBranch }) => { + try { + await addAttachedRepo({ + path: repoPath, + name: repoName, + branch: repoBranch + }); + notifyUser("Repository attached successfully", "success"); + setAttachModalOpen(false); + loadRepos(); + handleRescanGraph(); + } catch (err) { + notifyUser("Failed attaching repository: " + err.message, "error"); } }; // Filtered Repos const filteredRepos = useMemo(() => { return repos.filter((r) => { - const q = searchQuery.toLowerCase(); + const q = searchQuery.toLowerCase().trim(); if (!q) return true; return ( (r.name && r.name.toLowerCase().includes(q)) || @@ -199,32 +139,21 @@ export function AttachedReposPage({ }); }, [repos, searchQuery]); - // Find Notes referencing selected repo - const referencingNotes = useMemo(() => { - if (!selectedItem || !documents.length) return []; - const name = selectedItem.name || ""; - if (!name) return []; - - return documents.filter((doc) => { - if (doc.entryType !== "file") return false; - const content = doc.content || ""; - return ( - content.includes(`[[${name}]]`) || - content.includes(`@${name}`) || - content.includes(name) - ); - }); - }, [selectedItem, documents]); - - // Copy Markdown Link - const handleCopyLink = () => { - if (!selectedItem) return; - const linkText = `[[${selectedItem.name}]]`; - navigator.clipboard.writeText(linkText); - setCopiedLink(true); - notifyUser(`Copied ${linkText} to clipboard`, "info"); - setTimeout(() => setCopiedLink(false), 2000); - }; + // Map of repo name to reference counts in documents + const referenceCounts = useMemo(() => { + if (!documents.length) return {}; + const counts = {}; + for (const repo of repos) { + if (!repo.name) continue; + const count = documents.filter((doc) => { + if (doc.entryType !== "file") return false; + const c = doc.content || ""; + return c.includes(`[[${repo.name}]]`) || c.includes(`@${repo.name}`); + }).length; + counts[repo.id] = count; + } + return counts; + }, [repos, documents]); // Open in External IDE (VS Code) const handleOpenInIDE = (filePath) => { @@ -234,453 +163,125 @@ export function AttachedReposPage({ return (
- {/* Top Breadcrumb Bar */} -
- -
- -
- {/* Header Toolbar */} -
- setSidebarOpen((prev) => !prev)} - aria-label={sidebarOpen ? "Hide filters sidebar" : "Show filters sidebar"} - title={sidebarOpen ? "Hide filters sidebar" : "Show filters sidebar"} - > - {sidebarOpen ? : } - - -
- - setSearchQuery(e.target.value)} - /> -
- -
- - - {repos.length} {repos.length === 1 ? "Repository" : "Repositories"} Attached - -
+ +
+ + {repos.length} {repos.length === 1 ? "Repo" : "Repos"} +
-
- - {isScanning ? "Scanning..." : "Rescan Graph"} + + {isScanning ? "Scanning..." : "Rescan"} - {onOpenKnowledgeGraph && ( - - - Knowledge Graph - - )} - setAttachModalOpen(true)} title="Attach an external Git repository" > - - Attach Repo + + Attach Repo -
-
- - {/* Scanning progress banner */} - {scanProgress && ( -
- - {scanProgress} -
- )} + + } + /> - {/* Main Body */} -
- {/* Left Sidebar */} -
-
-
-
- - - Repositories - - {repos.length} -
+
+ {/* Toolbar: Search input */} + {repos.length > 0 && ( +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( - {repos.map((r) => ( - - ))} -
- -
-
- Knowledge Graph -
-
-
AST code symbols are extracted and merged into Notely's Knowledge Graph.
-
- Link from any note with [[SymbolName]] or @repo/path. -
-
-
+ )}
+ )} - {/* Canvas / Main Cards Grid */} + {/* Main Body */} +
- {filteredRepos.length === 0 ? ( + {repos.length === 0 ? (
-
+
No Repositories Attached Yet
-
- Attach your project's Git repositories (frontend, backend, microservices) to include code symbols, classes, functions, and routes in your Knowledge Graph. +
+ Attach your project's local Git repositories to index code symbols, classes, and functions into your workspace graph.
setAttachModalOpen(true)} - style={{ marginTop: "12px" }} > - - Attach First Repository + + Attach First Repository + +
+ ) : filteredRepos.length === 0 ? ( +
+ +
No Matching Repositories
+
+ No attached repositories matched "{searchQuery}". +
+ setSearchQuery("")} + > + Clear Filter
) : (
{filteredRepos.map((repo) => ( -
setSelectedItem(repo)} - > -
-
- - {repo.name} -
- - - {repo.branch || "main"} - -
- -
- {repo.path} -
- - {repo.headCommit && ( -
- - {repo.headCommit.slice(0, 7)} - {repo.remoteUrl && ( - - {repo.remoteUrl} - - )} -
- )} - -
- { - e.stopPropagation(); - openFolder(repo.path); - }} - title="Open folder in File Explorer" - > - - Folder - - - { - e.stopPropagation(); - handleOpenInIDE(repo.path); - }} - title="Open in VS Code" - > - - VS Code - - - { - e.stopPropagation(); - handleDetachRepo(repo.id, repo.name); - }} - title="Detach repository from workspace" - > - - Detach - -
-
+ repo={repo} + referencingCount={referenceCounts[repo.id] || 0} + onDetach={handleDetachRepo} + onOpenFolder={openFolder} + onOpenInIDE={handleOpenInIDE} + onCopyLink={(name) => notifyUser(`Copied [[${name}]] to clipboard`, "info")} + /> ))}
)}
- - {/* Right Inspector Drawer */} - {selectedItem && ( -
-
-
- - - {selectedItem.name} - -
- setSelectedItem(null)} - aria-label="Close drawer" - title="Close drawer" - > - - -
- -
-
- Local Path -
-
- {selectedItem.path} -
-
- - {selectedItem.branch && ( -
-
- Active Branch -
-
- - {selectedItem.branch} -
-
- )} - - {/* Referencing Notes Section */} -
-
- Referenced in Notes - {referencingNotes.length} -
- - {referencingNotes.length === 0 ? ( -
- No notes currently reference this repository. Mention it in any note with [[{selectedItem.name}]]. -
- ) : ( -
- {referencingNotes.map((doc) => ( -
{ - if (onOpenNote) onOpenNote(doc.filePath); - }} - > -
- - - {doc.title || doc.filePath} - -
- -
- ))} -
- )} -
- - {/* Action Buttons */} -
- - {copiedLink ? : } - {copiedLink ? "Copied Link!" : "Copy WikiLink [[Name]]"} - - - handleOpenInIDE(selectedItem.path)} - > - - Open in VS Code - - - {onOpenKnowledgeGraph && ( - - - View in Knowledge Graph - - )} -
-
- )}
{/* Attach Repo Dialog */} - {attachModalOpen && ( - setAttachModalOpen(false)} - title="Attach Git Repository" - size="md" - > -
-
- -
- setNewRepoPath(e.target.value)} - style={{ flex: 1 }} - required - /> - - - Browse - -
-
- -
- - setNewRepoName(e.target.value)} - /> -
- -
- - setNewRepoBranch(e.target.value)} - /> -
- -
- Notely will scan this repository for code files, classes, and exported functions and integrate them into your workspace's Knowledge Graph. -
- -
- setAttachModalOpen(false)} - > - Cancel - - - - Attach Repository - -
-
-
- )} + setAttachModalOpen(false)} + onAttach={handleAttachSubmit} + />
); } diff --git a/src/components/AudioRecorderBar.jsx b/src/components/AudioRecorderBar.jsx index e619ea21..04fe9927 100644 --- a/src/components/AudioRecorderBar.jsx +++ b/src/components/AudioRecorderBar.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Mic, Volume2, Users, Square, Pause, Play, X, Loader2 } from "lucide-react"; +import AppButton from "./AppButton"; import { createAudioMixer } from "../utils/audioMixer"; import { saveAudioRecording } from "../services/electron/mediaService"; import { transcribeAudio, getSTTPreferences } from "../services/sttService"; @@ -332,9 +333,9 @@ export default function AudioRecorderBar({ {/* Action Buttons */}
{!isRecording && !isProcessing && ( - + )} {isRecording && ( <> - + - + )} @@ -409,7 +407,7 @@ export default function AudioRecorderBar({ padding: "4px", display: "flex", alignItems: "center", - borderRadius: "var(--radius-sm, 4px)", + borderRadius: "var(--radius-default)", }} title="Cancel" > diff --git a/src/components/CalendarPage.jsx b/src/components/CalendarPage.jsx index 960ec715..1d56e596 100644 --- a/src/components/CalendarPage.jsx +++ b/src/components/CalendarPage.jsx @@ -1,12 +1,17 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Calendar, dateFnsLocalizer } from "react-big-calendar"; -import { format, parse, startOfWeek, getDay, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"; +import { + format, parse, startOfWeek, endOfWeek, getDay, + startOfMonth, endOfMonth, addMonths, subMonths, + addWeeks, subWeeks, addDays, subDays +} from "date-fns"; import { enUS } from "date-fns/locale"; import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, - CheckCircle2, AlertTriangle, FileText, Clock, + CheckCircle2, AlertTriangle, FileText, Clock, GitCommit, } from "lucide-react"; -import { getCalendarEvents } from "../services/electronService"; +import { getCalendarEvents, gitGetLog } from "../services/electronService"; +import SubpageHeader from "./layout/SubpageHeader"; import "react-big-calendar/lib/css/react-big-calendar.css"; import "../styles/CalendarPage.css"; @@ -22,9 +27,31 @@ const EVENT_TYPE_META = { "task-scheduled": { label: "Task Scheduled", className: "cal-event-task-sched", Icon: Clock }, "task-completed": { label: "Task Completed", className: "cal-event-task-done", Icon: CheckCircle2 }, "task-overdue": { label: "Task Overdue", className: "cal-event-task-overdue", Icon: AlertTriangle }, + "milestone": { label: "Milestones", className: "cal-event-milestone", Icon: GitCommit }, }; -function buildRbcEvents(rawResult) { +function parseEventDate(val) { + if (!val) return null; + if (val instanceof Date) return isNaN(val.getTime()) ? null : val; + if (typeof val === "number") { + const d = new Date(val); + return isNaN(d.getTime()) ? null : d; + } + if (typeof val === "string") { + const trimmed = val.trim(); + if (!trimmed) return null; + // Pure date YYYY-MM-DD -> local midnight + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + const [y, m, d] = trimmed.split("-").map(Number); + return new Date(y, m - 1, d, 0, 0, 0, 0); + } + const d = new Date(trimmed); + return isNaN(d.getTime()) ? null : d; + } + return null; +} + +function buildRbcEvents(rawResult, commits = []) { const events = []; let taskList = []; @@ -53,27 +80,26 @@ function buildRbcEvents(rawResult) { const type = task.type || task._eventType || (task.due_date ? "task-due" : "task-scheduled"); const meta = EVENT_TYPE_META[type] ?? EVENT_TYPE_META["task-due"]; - let start = task.start - ? new Date(task.start) - : task.scheduled_start - ? new Date(task.scheduled_start) - : task.due_date - ? new Date(task.due_date + "T00:00:00") - : null; + const rawStart = task.start || task.scheduled_start || task.due_date || task.completed_at || task.created_at; + const start = parseEventDate(rawStart); if (!start) continue; - let end = task.end - ? new Date(task.end) - : task.scheduled_end - ? new Date(task.scheduled_end) - : new Date(start.getTime() + 60 * 60000); + const rawEnd = task.end || task.scheduled_end; + let end = parseEventDate(rawEnd); + if (!end || end < start) { + end = task.allDay ? start : new Date(start.getTime() + 30 * 60000); + } + + const isAllDay = task.allDay !== undefined + ? Boolean(task.allDay) + : typeof rawStart === "string" && /^\d{4}-\d{2}-\d{2}$/.test(rawStart.trim()); events.push({ id: task.id || `task-${Math.random()}`, title: task.title || "Task", start, end, - allDay: Boolean(task.allDay || task.is_all_day || (!task.scheduled_start && task.due_date)), + allDay: isAllDay, resource: { ...task, _eventType: type }, className: meta.className, }); @@ -81,24 +107,46 @@ function buildRbcEvents(rawResult) { for (const note of noteList) { const type = note.type || note._eventType || "note-updated"; - const startStr = note.start || note.updatedAt || note.created_at || note.createdAt; - if (!startStr) continue; + const rawStart = note.start || note.updatedAt || note.created_at || note.createdAt || note.mtime; + const start = parseEventDate(rawStart); + if (!start) continue; - const start = new Date(startStr); - const end = note.end ? new Date(note.end) : new Date(start.getTime() + 30 * 60000); + const rawEnd = note.end; + const end = parseEventDate(rawEnd) || (note.allDay ? start : new Date(start.getTime() + 30 * 60000)); const meta = EVENT_TYPE_META[type] ?? EVENT_TYPE_META["note-updated"]; + const isAllDay = note.allDay !== undefined + ? Boolean(note.allDay) + : typeof rawStart === "string" && /^\d{4}-\d{2}-\d{2}$/.test(rawStart.trim()); + events.push({ id: note.id || `note-${Math.random()}`, title: note.title || "Note", start, end, - allDay: Boolean(note.allDay ?? true), + allDay: isAllDay, resource: { ...note, _eventType: type }, className: meta.className, }); } + if (Array.isArray(commits)) { + for (const commit of commits) { + const start = parseEventDate(commit.date || commit.timestamp || commit.created_at); + if (!start) continue; + + events.push({ + id: commit.hash || `milestone-${Math.random()}`, + title: commit.message ? `Milestone: ${commit.message}` : "Milestone", + start, + end: new Date(start.getTime() + 15 * 60000), + allDay: false, + resource: { ...commit, _eventType: "milestone" }, + className: EVENT_TYPE_META.milestone.className, + }); + } + } + return events; } @@ -119,17 +167,21 @@ function EventTypeToggle({ type, active, onToggle }) { ); } -function EventCard({ event, onOpenNote, onOpenTask, onClose }) { +function EventCard({ event, onOpenNote, onOpenTask, onOpenVersionControl, onClose }) { const type = event.resource?._eventType ?? ""; const meta = EVENT_TYPE_META[type] ?? {}; const Icon = meta.Icon ?? CalendarIcon; const task = type?.startsWith("task") ? event.resource : null; const note = type?.startsWith("note") ? event.resource : null; + const isMilestone = type === "milestone"; + const commit = isMilestone ? event.resource : null; + const notePath = note?.filePath || note?.sourcePath; + const taskPath = task?.source_path || task?.sourcePath; return (
- {meta.label} + {meta.label || type} @@ -138,31 +190,48 @@ function EventCard({ event, onOpenNote, onOpenTask, onClose }) { {event.start && (
{event.allDay ? format(event.start, "MMM d, yyyy") : format(event.start, "MMM d, yyyy 'at' h:mm a")} - {event.end && !event.allDay && ` – ${format(event.end, "h:mm a")}`} + {event.end && !event.allDay && event.end.getTime() !== event.start.getTime() && ` – ${format(event.end, "h:mm a")}`} +
+ )} + {task?.status && ( +
+ Status: {task.status} + {task.priority ? Priority: P{task.priority} : null} +
+ )} + {isMilestone && commit && ( +
+ Commit: {commit.shortHash || commit.hash?.slice(0, 7)} + {commit.author && by {commit.author}}
)}
- {task && ( - )} - {note && ( - )} - {task?.source_path && ( - )} + {isMilestone && onOpenVersionControl && ( + + )}
); } -export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { +export function CalendarPage({ onBack, onOpenNote, onOpenTask, onOpenVersionControl, workspacePath }) { const [date, setDate] = useState(new Date()); const [view, setView] = useState("month"); const [events, setEvents] = useState([]); @@ -181,18 +250,26 @@ export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { // Pad a week on each side so the calendar grid edges are covered const padStart = new Date(start.getTime() - 7 * 24 * 60 * 60000); const padEnd = new Date(end.getTime() + 7 * 24 * 60 * 60000); - const result = await getCalendarEvents( - format(padStart, "yyyy-MM-dd"), - format(padEnd, "yyyy-MM-dd") - ); - const built = buildRbcEvents(result); + + const [calRes, gitRes] = await Promise.allSettled([ + getCalendarEvents( + format(padStart, "yyyy-MM-dd"), + format(padEnd, "yyyy-MM-dd") + ), + workspacePath ? gitGetLog({ workspacePath, limit: 100 }) : Promise.resolve(null), + ]); + + const rawResult = calRes.status === "fulfilled" ? calRes.value : []; + const commits = gitRes.status === "fulfilled" && gitRes.value?.ok ? gitRes.value.data : []; + + const built = buildRbcEvents(rawResult, commits); setEvents(built); } catch (err) { setError(err.message); } finally { setLoading(false); } - }, []); + }, [workspacePath]); useEffect(() => { void loadEvents(date); }, [date, loadEvents]); @@ -227,55 +304,75 @@ export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { } }, [onOpenTask]); - return ( -
- {/* App-Standard Topbar Navigation */} -
- - -
- {/* Month Navigator */} -
- - {format(date, "MMMM yyyy")} - - -
+ const handlePrev = useCallback(() => { + setDate(d => { + if (view === "day") return subDays(d, 1); + if (view === "week") return subWeeks(d, 1); + return subMonths(d, 1); + }); + }, [view]); -
- - {visibleEvents.length} events -
+ const handleNext = useCallback(() => { + setDate(d => { + if (view === "day") return addDays(d, 1); + if (view === "week") return addWeeks(d, 1); + return addMonths(d, 1); + }); + }, [view]); + + const dateTitle = useMemo(() => { + if (view === "day") return format(date, "MMMM d, yyyy"); + if (view === "week") { + const sw = startOfWeek(date); + const ew = endOfWeek(date); + return `${format(sw, "MMM d")} – ${format(ew, "MMM d, yyyy")}`; + } + return format(date, "MMMM yyyy"); + }, [date, view]); - {/* View mode toggle: Month vs Week vs Day */} -
- {["month", "week", "day"].map(v => ( - - ))} -
-
-
+ {dateTitle} + + +
+ +
+ + {visibleEvents.length} events +
+ + {/* View mode toggle: Month vs Week vs Day */} +
+ {["month", "week", "day"].map(v => ( + + ))} +
+ + } + /> {/* Filter bar */}
@@ -315,6 +412,7 @@ export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { event={selectedEvent} onOpenNote={onOpenNote} onOpenTask={onOpenTask} + onOpenVersionControl={onOpenVersionControl} onClose={() => setSelectedEvent(null)} />
diff --git a/src/components/CodeBlockModal.jsx b/src/components/CodeBlockModal.jsx index 98fd2647..846c3683 100644 --- a/src/components/CodeBlockModal.jsx +++ b/src/components/CodeBlockModal.jsx @@ -337,7 +337,7 @@ export function CodeBlockModal({ open, onClose, onSave, initialLanguage = "", in padding: "8px 12px", background: "#181a1f", border: "1px solid #282c34", - borderRadius: "4px", + borderRadius: "var(--radius-default)", fontFamily: "Consolas, Monaco, monospace", fontSize: "12px", color: "#61afef" @@ -352,7 +352,7 @@ export function CodeBlockModal({ open, onClose, onSave, initialLanguage = "", in padding: "8px 12px", background: "#181a1f", border: "1px solid #282c34", - borderRadius: "4px", + borderRadius: "var(--radius-default)", maxHeight: execResult.isHtml ? "270px" : "150px", overflowY: "auto", fontFamily: "Consolas, Monaco, monospace", @@ -378,7 +378,7 @@ export function CodeBlockModal({ open, onClose, onSave, initialLanguage = "", in height: "200px", border: "none", background: "#ffffff", - borderRadius: "4px", + borderRadius: "var(--radius-default)", marginTop: "4px" }} /> diff --git a/src/components/DashboardPanels.jsx b/src/components/DashboardPanels.jsx index acfd2680..d9b6e3eb 100644 --- a/src/components/DashboardPanels.jsx +++ b/src/components/DashboardPanels.jsx @@ -223,7 +223,7 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, className="dashboard-task-badge dashboard-task-overdue" onClick={() => onAction("tasks-overdue")} data-tooltip={`${overdueCount} task${overdueCount > 1 ? "s" : ""} past deadline!`} - style={{ cursor: "pointer", background: "var(--surface-danger)", color: "var(--status-danger-text)", border: "1px solid var(--status-danger-border)", padding: "1px 6px", borderRadius: "100px", fontSize: "10px", fontWeight: "bold", display: "inline-flex", alignItems: "center", gap: "3px" }} + style={{ cursor: "pointer", background: "var(--surface-danger)", color: "var(--status-danger-text)", border: "1px solid var(--status-danger-border)", padding: "1px 6px", borderRadius: "9999px", fontSize: "10px", fontWeight: "bold", display: "inline-flex", alignItems: "center", gap: "3px" }} > {overdueCount} overdue @@ -319,7 +319,7 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, Recycle Bin
- + {trashCount}
diff --git a/src/components/DocumentDetail.jsx b/src/components/DocumentDetail.jsx index 2a359858..d47417ab 100644 --- a/src/components/DocumentDetail.jsx +++ b/src/components/DocumentDetail.jsx @@ -358,6 +358,30 @@ export function DocumentDetail({ const MAX_EDITOR_HISTORY = 200; const textareaRef = useRef(null); const content = document?.rawNotes || ""; + const derivedBreadcrumbs = useMemo(() => { + if (Array.isArray(breadcrumbs) && breadcrumbs.length > 0) { + return breadcrumbs; + } + if (!document?.filePath || !workspacePath) { + return []; + } + const normDoc = String(document.filePath).replace(/\\/g, "/"); + const normWs = String(workspacePath).replace(/\\/g, "/"); + if (!normDoc.startsWith(normWs)) { + return []; + } + const rel = normDoc.slice(normWs.length).replace(/^\/+/, ""); + const parts = rel.split("/").filter(Boolean); + parts.pop(); // Note file name is current + const result = [{ label: "Notes", path: workspacePath }]; + let cur = normWs; + for (const seg of parts) { + cur = `${cur}/${seg}`; + result.push({ label: seg, path: cur }); + } + return result; + }, [breadcrumbs, document?.filePath, workspacePath]); + const fullWorkingCopyContent = useMemo(() => { const parts = []; if (document?.header?.trim()) parts.push(document.header.trim()); @@ -571,7 +595,7 @@ export function DocumentDetail({ if (res?.ok) { onNotify?.(`Restored document to version ${commitLabel}`, "success"); setIsTimeMachineOpen(false); - onReloadFromDisk?.(fPath); + onReloadFromDisk?.(fPath, { silent: true }); } else { onNotify?.(res?.error || "Restore failed.", "error"); } @@ -1120,7 +1144,6 @@ export function DocumentDetail({ pdfQualityPreset, }); if (!result?.canceled) { - onNotify?.("PDF downloaded.", "success"); setPdfOptionsOpen(false); } } catch (error) { @@ -1168,7 +1191,7 @@ export function DocumentDetail({ {!isTimeMachineOpen && ( { if (!filePath) return; - await showInFolder(filePath); + try { + const res = await showInFolder(filePath); + if (res === false) { + showErrorToast("Unable to reveal file location. It may have been moved or deleted."); + } + } catch { + showErrorToast("Unable to reveal file in folder."); + } }; const handleOpenFile = async (filePath) => { if (!filePath) return; - await openExportFile(filePath); + try { + const res = await openExportFile(filePath); + if (res === false) { + showErrorToast("Unable to open exported file. It may have been moved or deleted."); + } + } catch { + showErrorToast("Unable to open exported file."); + } }; const handleRemove = async (id) => { await removeExportRecord(id); setHistory((prev) => prev.filter((item) => item.id !== id)); + showInfoToast("Export record removed."); }; const handleClearAll = async () => { @@ -124,6 +141,7 @@ export function DownloadsPage({ onBack }) { await clearExportHistory(); setHistory([]); + showInfoToast("Export history cleared."); }; const handleOpenDownloadsFolder = async () => { @@ -183,54 +201,47 @@ export function DownloadsPage({ onBack }) { return (
-
- - -
-
- - {filteredHistory.length} items -
+ +
+ + {filteredHistory.length} items +
+ + {defaultDir && ( + + + Downloads Folder + + )} - {defaultDir && ( - - - Downloads Folder - - )} - - {history.length > 0 && ( - - - Clear History - - )} + {history.length > 0 && ( + + + Clear History + + )} - - - Refresh - -
-
+ + + Refresh + + + } + />
diff --git a/src/components/DownloadsPopover.jsx b/src/components/DownloadsPopover.jsx index 564f31fd..8e614de0 100644 --- a/src/components/DownloadsPopover.jsx +++ b/src/components/DownloadsPopover.jsx @@ -7,6 +7,7 @@ import { FileText, FileCode, Image as ImageIcon, + X, } from "lucide-react"; import { openExportFile, showInFolder } from "../services/electronService.js"; @@ -65,9 +66,18 @@ export function DownloadsPopover({ >
- - Downloads + + Recent Downloads
+
diff --git a/src/components/DrawioBlock.jsx b/src/components/DrawioBlock.jsx index 601a2cdd..a2a8205d 100644 --- a/src/components/DrawioBlock.jsx +++ b/src/components/DrawioBlock.jsx @@ -62,9 +62,7 @@ export function DrawioBlock({ imagePath, diagramId, documentPath, onUpdate, onNo customExportType: "diagram_drawio", category: "diagram", }); - if (result?.success) { - onNotify?.(`Diagram exported to ${result.filename}`, "success"); - } else { + if (!result?.success) { onNotify?.(result?.error || "Failed to export diagram.", "error"); } } catch (err) { diff --git a/src/components/EmbeddingsPage.jsx b/src/components/EmbeddingsPage.jsx index 2824e265..5853e002 100644 --- a/src/components/EmbeddingsPage.jsx +++ b/src/components/EmbeddingsPage.jsx @@ -26,6 +26,9 @@ import { } from '../services/electronService'; import { OverlayDialog } from './OverlayDialog'; import { useConfirm } from '../hooks/useConfirm'; +import AppButton from './AppButton'; +import SubpageHeader from './layout/SubpageHeader'; +import { showSuccessToast, showErrorToast, showInfoToast } from '../utils/notificationUtils'; import '../styles/KnowledgeGraph.css'; // Reuses base layout rules for unified styling @@ -75,9 +78,7 @@ export default function EmbeddingsPage({ onBack }) { if (isRebuilding && res.data.queueSize === 0) { setIsRebuilding(false); setShowProgressModal(false); - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Embeddings DB successfully rebuilt.', type: 'success' } - })); + showSuccessToast('Embeddings DB successfully rebuilt.'); } } } else { @@ -134,6 +135,7 @@ export default function EmbeddingsPage({ onBack }) { if (status.isPaused) { await aiResumeWorker(); setStatus(prev => ({ ...prev, isPaused: false })); + showInfoToast('Vector embeddings worker resumed.'); } else { const confirmed = await confirm({ title: 'Pause Vector Embeddings Worker?', @@ -145,9 +147,11 @@ export default function EmbeddingsPage({ onBack }) { if (!confirmed) return; await aiPauseWorker(); setStatus(prev => ({ ...prev, isPaused: true })); + showInfoToast('Vector embeddings worker paused.'); } } catch (err) { console.error(err); + showErrorToast(`Failed to update worker status: ${err?.message || 'Unknown error'}`); } }; @@ -165,16 +169,16 @@ export default function EmbeddingsPage({ onBack }) { setLoading(true); const res = await aiRebuildEmbeddings(); if (res.success) { - window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Embeddings rebuild triggered.', type: 'success' } - })); + showSuccessToast('Embeddings rebuild triggered.'); setIsRebuilding(true); await loadEmbeddingsStatus(); } else { setError(res.error || 'Failed to clear data'); + showErrorToast(res.error || 'Failed to clear embeddings data.'); } } catch (err) { setError(err.message); + showErrorToast(err.message || 'Error triggering embeddings rebuild.'); } finally { setLoading(false); } @@ -182,16 +186,68 @@ export default function EmbeddingsPage({ onBack }) { return (
- {/* Unified topbar navigation breadcrumb */} -
- -
+ + + + + + + {status.isPaused ? : } + + + + + + + { + const confirmed = await confirm({ + title: 'Clear Embeddings Cache?', + message: 'Are you sure you want to clear all indexed vector embeddings data from cache?', + confirmLabel: 'Clear Cache', + cancelLabel: 'Cancel', + variant: 'danger' + }); + if (confirmed) { + await aiClearEmbeddingsData(); + loadEmbeddingsStatus(); + } + }} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '32px', height: '32px', padding: 0, color: 'var(--text-danger)' }} + title="Clear Data" + > + + +
+ } + />
{/* Header Actions Bar */} @@ -219,7 +275,7 @@ export default function EmbeddingsPage({ onBack }) { background: 'var(--surface-muted)', border: '1px solid var(--accent-solid)', padding: '0 12px', - borderRadius: '6px', + borderRadius: 'var(--radius-default)', fontSize: '11px', color: 'var(--text-strong)', marginLeft: 'auto', @@ -232,7 +288,7 @@ export default function EmbeddingsPage({ onBack }) {
Indexing vector embeddings... -
+
0 ? Math.max(0, Math.min(100, ((status.queueTotal - status.queueSize) / status.queueTotal) * 100)) : 0}%`, height: '100%', @@ -246,7 +302,7 @@ export default function EmbeddingsPage({ onBack }) {
) : ( -
+
Provider: {preferences.embeddingProvider === 'internal' ? 'Local' : 'HuggingFace'} @@ -270,8 +326,9 @@ export default function EmbeddingsPage({ onBack }) { )} {!modelStatus.downloaded && ( - + )}
Chunks: {status.totalChunks} | Indexed Notes: {status.indexedNotes}
- -
- -
- - - - - - - -
@@ -354,7 +357,7 @@ export default function EmbeddingsPage({ onBack }) {
{/* Vector Engine Stats Card */} -
+

Engine Metadata @@ -382,7 +385,7 @@ export default function EmbeddingsPage({ onBack }) {

{/* Indexing Event Logs */} -
+

Indexing Logs @@ -399,7 +402,7 @@ export default function EmbeddingsPage({ onBack }) {

- + - +
{/* Selected Chunk Detail Card */} @@ -476,7 +481,7 @@ export default function EmbeddingsPage({ onBack }) {
Content -
+                    
                       {selectedChunk.content}
                     
@@ -525,7 +530,7 @@ export default function EmbeddingsPage({ onBack }) { {chunk.note_path.split(/[/\\]/).pop()} - + {chunk.chunk_type || 'text'} @@ -564,7 +569,7 @@ export default function EmbeddingsPage({ onBack }) {
-
+
0 ? Math.max(0, Math.min(100, ((status.queueTotal - status.queueSize) / status.queueTotal) * 100)) : 0}%`, height: '100%', @@ -580,13 +585,13 @@ export default function EmbeddingsPage({ onBack }) {
- +
diff --git a/src/components/ExcalidrawBlock.jsx b/src/components/ExcalidrawBlock.jsx index 58712b3e..e0a5f364 100644 --- a/src/components/ExcalidrawBlock.jsx +++ b/src/components/ExcalidrawBlock.jsx @@ -63,9 +63,7 @@ export function ExcalidrawBlock({ imagePath, diagramId, documentPath, originAsse customExportType: "diagram_excalidraw", category: "diagram", }); - if (result?.success) { - onNotify?.(`Diagram exported to ${result.filename}`, "success"); - } else { + if (!result?.success) { onNotify?.(result?.error || "Failed to export diagram.", "error"); } } catch (err) { diff --git a/src/components/GitCommitDialog.jsx b/src/components/GitCommitDialog.jsx index 05034fc4..0aef48e6 100644 --- a/src/components/GitCommitDialog.jsx +++ b/src/components/GitCommitDialog.jsx @@ -54,6 +54,14 @@ export function GitCommitDialog({ ); } + function selectAll() { + setSelectedPaths(stagedFiles.map((f) => f.path)); + } + + function deselectAll() { + setSelectedPaths([]); + } + async function handleCommit() { const trimmed = message.trim(); if (!trimmed) { @@ -94,13 +102,13 @@ export function GitCommitDialog({
+ + +
+
{stagedFiles.map((f) => (
); } export default GitStatusBar; + diff --git a/src/components/GitVersionControlPage.jsx b/src/components/GitVersionControlPage.jsx index 529af6a0..b32a57a8 100644 --- a/src/components/GitVersionControlPage.jsx +++ b/src/components/GitVersionControlPage.jsx @@ -2,28 +2,27 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { GitBranch, GitCommit, - GitCompare, - Tag, Cloud, - Settings, + ArrowUpDown, RefreshCw, Plus, Trash2, Check, + CheckCircle2, AlertTriangle, ExternalLink, - RotateCcw, Upload, Download, - CornerDownLeft, - Layers, + FileEdit, + FilePlus2, } from "lucide-react"; import AppButton from "./AppButton"; import AppInput from "./AppInput"; +import AppTextarea from "./AppTextarea"; +import SubpageHeader from "./layout/SubpageHeader"; +import { OverlayDialog } from "./OverlayDialog"; import { GitCommitTimeline } from "./GitCommitTimeline"; -import { GitDiffViewer } from "./GitDiffViewer"; import { GitCommitDialog } from "./GitCommitDialog"; -import OverlayDialog from "./OverlayDialog"; import { gitDetect, gitGetRepoInfo, @@ -31,18 +30,6 @@ import { gitGetStatus, gitGetLog, gitCommit, - gitGetFileAtCommit, - gitListBranches, - gitCreateBranch, - gitDeleteBranch, - gitSwitchBranch, - gitListTags, - gitCreateTag, - gitDeleteTag, - gitStashList, - gitStashPush, - gitStashPop, - gitStashDrop, gitListRemotes, gitAddRemote, gitRemoveRemote, @@ -52,20 +39,23 @@ import { gitGetCommitFiles, } from "../services/electronService"; -// ── Tab IDs ────────────────────────────────────────────────────────────────── +// ── Tab Config ──────────────────────────────────────────────────────────────── const TABS = [ - { id: "status", label: "Status", icon: Check }, - { id: "history", label: "History", icon: GitCommit }, - { id: "compare", label: "Compare", icon: GitCompare }, - { id: "branches", label: "Branches", icon: GitBranch }, - { id: "tags", label: "Tags", icon: Tag }, - { id: "stashes", label: "Stashes", icon: Layers }, - { id: "remotes", label: "Remotes", icon: Cloud }, - { id: "settings", label: "Settings", icon: Settings }, + { id: "changes", label: "Pending Changes", icon: FileEdit }, + { id: "history", label: "Milestones Log", icon: GitCommit }, + { id: "sync", label: "Cloud Sync & Backup", icon: Cloud }, ]; -// ── Empty states ────────────────────────────────────────────────────────────── +function normalizeTabId(tabId) { + if (!tabId) return "changes"; + if (tabId === "status" || tabId === "commit") return "changes"; + if (tabId === "remotes") return "sync"; + if (tabId === "compare" || tabId === "branches" || tabId === "tags" || tabId === "stashes") return "history"; + return tabId; +} + +// ── Empty States ────────────────────────────────────────────────────────────── function NoGitState() { return ( @@ -74,7 +64,7 @@ function NoGitState() {

Git not detected

Git is not installed or not found on your system PATH. - Version control requires Git to be installed. + Install Git to enable version history, milestones, and cloud sync.

); } -// ── Status Tab ──────────────────────────────────────────────────────────────── +// ── Changes & Commit Tab ────────────────────────────────────────────────────── -function StatusTab({ status, workspacePath, onRefresh, onNotify, onCommitSuccess }) { - const [commitDialogOpen, setCommitDialogOpen] = useState(false); +function ChangesTab({ status, workspacePath, onRefresh, onCommitSuccess }) { const { files = [], branch = "", ahead = 0, behind = 0 } = status || {}; + const [selectedPaths, setSelectedPaths] = useState([]); + const [message, setMessage] = useState(""); + const [committing, setCommitting] = useState(false); + const [error, setError] = useState(null); - const getStatusLetter = (statusStr) => { - if (!statusStr) return "M"; - const s = statusStr.toLowerCase(); - if (s.startsWith("mod")) return "M"; - if (s.startsWith("add")) return "A"; - if (s.startsWith("del")) return "D"; - if (s.startsWith("ren")) return "R"; - return statusStr.charAt(0).toUpperCase(); - }; - - const modified = files.filter((f) => f.status !== "untracked"); - const untracked = files.filter((f) => f.status === "untracked"); - - async function handleCommit(payload) { - const result = await gitCommit({ workspacePath, ...payload }); - if (!result?.ok) throw new Error(result?.error || "Commit failed."); - onNotify?.("Committed successfully.", "success"); - onCommitSuccess?.(); - onRefresh?.(); - } - - return ( -
-
-
-
- - - Refresh - -
- - {files.length === 0 ? ( -
- -

Working tree is clean. No changes to commit.

-
- ) : ( -
- {modified.length > 0 && ( -
-

Changes ({modified.length})

-
    - {modified.map((f) => ( -
  • - {getStatusLetter(f.status)} - {f.path} -
  • - ))} -
-
- )} - - {untracked.length > 0 && ( -
-

Untracked ({untracked.length})

-
    - {untracked.map((f) => ( -
  • - ? - {f.path} -
  • - ))} -
-
- )} - -
- )} - - setCommitDialogOpen(false)} - onCommit={handleCommit} - stagedFiles={files} - workspacePath={workspacePath} - /> -
- ); -} - -// ── History Tab ─────────────────────────────────────────────────────────────── + // Auto-select all changed files when status updates + useEffect(() => { + setSelectedPaths(files.map((f) => f.path)); + }, [files]); -function HistoryTab({ commits, loading, error, onNotify, onCreateTag }) { - async function handleCompare() { - onNotify?.("Switch to the Compare tab to compare commits.", "info"); + function togglePath(filePath) { + setSelectedPaths((prev) => + prev.includes(filePath) ? prev.filter((p) => p !== filePath) : [...prev, filePath] + ); } - return ( -
- -
- ); -} - -// ── Compare Tab ─────────────────────────────────────────────────────────────── - -function CompareTab({ _commits, workspacePath, currentFilePath, documents = [], repoRoot }) { - const [hashA, setHashA] = useState(""); - const [hashB, setHashB] = useState(""); - const [filePathFilter, setFilePathFilter] = useState(""); - const [fileCommits, setFileCommits] = useState([]); - const [loadingCommits, setLoadingCommits] = useState(false); - const [contentA, setContentA] = useState(""); - const [contentB, setContentB] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [compared, setCompared] = useState(false); - - // List of workspace markdown documents relative to repoRoot - const workspaceFiles = useMemo(() => { - const root = (repoRoot || workspacePath).replace(/\\/g, "/").replace(/\/$/, ""); - return documents - .filter((doc) => doc.entryType === "file" && doc.filePath?.endsWith(".md")) - .map((doc) => { - const target = doc.filePath.replace(/\\/g, "/"); - const rel = target.startsWith(root) - ? target.slice(root.length).replace(/^\//, "") - : target; - return { - absolutePath: doc.filePath, - relativePath: rel, - }; - }) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)); - }, [documents, repoRoot, workspacePath]); - - // Set initial selected file if currentFilePath is provided - useEffect(() => { - if (currentFilePath && workspaceFiles.length > 0) { - const found = workspaceFiles.find( - (f) => f.absolutePath.toLowerCase() === currentFilePath.toLowerCase() - ); - if (found) { - setFilePathFilter(found.absolutePath); - } else { - setFilePathFilter(workspaceFiles[0].absolutePath); - } - } else if (workspaceFiles.length > 0 && !filePathFilter) { - setFilePathFilter(workspaceFiles[0].absolutePath); + async function handleCommit() { + const trimmed = message.trim(); + if (!trimmed) { + setError("Please provide a milestone description."); + return; } - }, [currentFilePath, workspaceFiles, filePathFilter]); - - // Load commits when selected file changes - useEffect(() => { - if (!filePathFilter) { - setFileCommits([]); + if (selectedPaths.length === 0) { + setError("Select at least one note to include in this milestone."); return; } - let active = true; - setLoadingCommits(true); - setError(null); - setCompared(false); - - gitGetLog({ workspacePath, filePath: filePathFilter, limit: 100 }) - .then((res) => { - if (!active) return; - if (res?.ok) { - const list = res.data || []; - setFileCommits(list); - if (list.length > 0) { - setHashA(list[0]?.hash || ""); - setHashB("WORKING"); - } else { - setHashA(""); - setHashB(""); - } - } else { - setFileCommits([]); - setHashA(""); - setHashB(""); - } - }) - .catch((err) => { - if (!active) return; - setError(err?.message || "Failed to load file history."); - }) - .finally(() => { - if (active) setLoadingCommits(false); - }); - - return () => { - active = false; - }; - }, [filePathFilter, workspacePath]); - - async function handleCompare() { - if (!hashA || !hashB || !filePathFilter.trim()) return; - setLoading(true); + setCommitting(true); setError(null); - setCompared(false); try { - const fp = filePathFilter.trim(); - const [rA, rB] = await Promise.all([ - gitGetFileAtCommit({ workspacePath, commitHash: hashA, filePath: fp }), - gitGetFileAtCommit({ workspacePath, commitHash: hashB, filePath: fp }), - ]); - - if (!rA?.ok) throw new Error(rA?.error || "Failed to load version A."); - if (!rB?.ok) throw new Error(rB?.error || "Failed to load version B."); - - setContentA(rA.data); - setContentB(rB.data); - setCompared(true); + const result = await gitCommit({ + workspacePath, + message: trimmed, + filePaths: selectedPaths, + }); + if (!result?.ok) throw new Error(result?.error || "Commit failed."); + + setMessage(""); + onCommitSuccess?.(); + onRefresh?.(); } catch (err) { - setError(err?.message || "Failed to compare."); + setError(err?.message || "Failed to save milestone."); } finally { - setLoading(false); + setCommitting(false); } } - return ( -
-
-
- - -
- -
- - -
+ const canCommit = message.trim().length > 0 && selectedPaths.length > 0 && !committing; - + const [filterText, setFilterText] = useState(""); -
- - -
+ const filteredFiles = useMemo(() => { + if (!filterText.trim()) return files; + const q = filterText.toLowerCase(); + return files.filter((f) => f.path.toLowerCase().includes(q)); + }, [files, filterText]); - - - {loading ? "Comparing…" : "Compare"} - - - {error &&

{error}

} -
- - {compared && ( - - )} -
- ); -} - -// ── Branches Tab ────────────────────────────────────────────────────────────── - -function BranchesTab({ workspacePath, onNotify, onRefresh, _currentBranch }) { - const [branches, setBranches] = useState([]); - const [loading, setLoading] = useState(false); - const [newName, setNewName] = useState(""); - const [creating, setCreating] = useState(false); - const [, setError] = useState(null); + const allFilteredSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedPaths.includes(f.path)); + const someFilteredSelected = filteredFiles.some((f) => selectedPaths.includes(f.path)) && !allFilteredSelected; - const loadBranches = useCallback(async () => { - setLoading(true); - try { - const result = await gitListBranches(workspacePath); - if (result?.ok) setBranches(result.data?.branches || []); - else setError(result?.error); - } catch (err) { - setError(err?.message); - } finally { - setLoading(false); - } - }, [workspacePath]); - - useEffect(() => { loadBranches(); }, [loadBranches]); - - async function handleCreate() { - if (!newName.trim()) return; - setCreating(true); - const result = await gitCreateBranch({ workspacePath, name: newName.trim() }); - setCreating(false); - if (result?.ok) { - onNotify?.(`Branch "${newName.trim()}" created.`, "success"); - setNewName(""); - loadBranches(); - } else { - onNotify?.(result?.error || "Failed to create branch.", "error"); - } - } - - async function handleSwitch(name) { - const result = await gitSwitchBranch({ workspacePath, name }); - if (result?.ok) { - onNotify?.(`Switched to branch "${name}".`, "success"); - onRefresh?.(); - loadBranches(); + function toggleSelectAllFiltered() { + if (allFilteredSelected) { + const filteredPathSet = new Set(filteredFiles.map((f) => f.path)); + setSelectedPaths((prev) => prev.filter((p) => !filteredPathSet.has(p))); } else { - onNotify?.(result?.error || "Failed to switch branch.", "error"); + const newSelected = new Set([...selectedPaths, ...filteredFiles.map((f) => f.path)]); + setSelectedPaths(Array.from(newSelected)); } } - async function handleDelete(name) { - if (!window.confirm(`Delete branch "${name}"? This cannot be undone.`)) return; - const result = await gitDeleteBranch({ workspacePath, name }); - if (result?.ok) { - onNotify?.(`Branch "${name}" deleted.`, "success"); - loadBranches(); - } else { - onNotify?.(result?.error || "Failed to delete branch.", "error"); - } + function splitPath(filePath) { + const normalized = (filePath || "").replace(/\\/g, "/"); + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) return { dir: "", file: normalized }; + return { + dir: normalized.slice(0, lastSlash + 1), + file: normalized.slice(lastSlash + 1), + }; } return ( -
-
-

Create branch

-
- setNewName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} - placeholder="New branch name" - aria-label="New branch name" - /> - - - {creating ? "Creating…" : "Create"} - +
+ {/* Branch & Sync Status Banner */} +
+
+
-
-

Branches

- {loading ? ( -
Loading branches…
- ) : ( -
    - {branches.filter((b) => !b.remote).map((b) => ( -
  • -
  • - ))} -
- )} -
-
- ); -} - -// ── Tags Tab ────────────────────────────────────────────────────────────────── - -function TagsTab({ workspacePath, onNotify }) { - const [tags, setTags] = useState([]); - const [loading, setLoading] = useState(false); - const [newTagName, setNewTagName] = useState(""); - const [creating, setCreating] = useState(false); + {files.length === 0 ? ( +
+ +

All notes up to date

+

No uncommitted note modifications found in this workspace.

+
+ ) : ( +
+ {/* Milestone Composer Card */} +
+
+ + Save Milestone Checkpoint + + + {selectedPaths.length} of {files.length} notes selected + +
- const loadTags = useCallback(async () => { - setLoading(true); - try { - const result = await gitListTags(workspacePath); - if (result?.ok) setTags(result.data || []); - } catch { /* ignore */ } finally { - setLoading(false); - } - }, [workspacePath]); + { + setMessage(e.target.value); + if (error) setError(null); + }} + placeholder="Describe your milestone (e.g. Completed docs restructure, updated getting started guide)…" + rows={3} + disabled={committing} + style={{ width: "100%", minHeight: "68px", boxSizing: "border-box" }} + onKeyDown={(e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + handleCommit(); + } + }} + /> - useEffect(() => { loadTags(); }, [loadTags]); + {error && ( +

+ {error} +

+ )} - async function handleCreate() { - if (!newTagName.trim()) return; - setCreating(true); - const result = await gitCreateTag({ workspacePath, name: newTagName.trim() }); - setCreating(false); - if (result?.ok) { - onNotify?.(`Tag "${newTagName.trim()}" created.`, "success"); - setNewTagName(""); - loadTags(); - } else { - onNotify?.(result?.error || "Failed to create tag.", "error"); - } - } +
+ + Press Ctrl+Enter to save + + + + {committing ? "Saving…" : "Save Milestone"} + +
+
- async function handleDelete(name) { - if (!window.confirm(`Delete tag "${name}"?`)) return; - const result = await gitDeleteTag({ workspacePath, name }); - if (result?.ok) { - onNotify?.(`Tag "${name}" deleted.`, "success"); - loadTags(); - } else { - onNotify?.(result?.error || "Failed to delete tag.", "error"); - } - } + {/* Structured Notes Activity Card */} +
+ {/* Header / Filter Toolbar */} +
+
+ + + {selectedPaths.length} / {files.length} + +
- return ( -
-
-

Create tag

-
- setNewTagName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} - placeholder="Tag name (e.g. v1.0)" - aria-label="New tag name" - /> - - - {creating ? "Creating…" : "Tag HEAD"} - -
-
+ {/* Quick Filter Search */} +
+ setFilterText(e.target.value)} + placeholder="Filter modified notes…" + aria-label="Filter modified notes" + style={{ height: "28px", fontSize: "var(--font-size-caption)" }} + /> +
+
-
-

Tags ({tags.length})

- {loading ? ( -
Loading tags…
- ) : tags.length === 0 ? ( -

No tags yet.

- ) : ( -
    - {tags.map((t) => ( -
  • -
  • - ))} -
- )} -
+ ) : ( +
    + {filteredFiles.map((f) => { + const isSelected = selectedPaths.includes(f.path); + const isUntracked = f.status === "untracked"; + const { dir, file } = splitPath(f.path); + + return ( +
  • togglePath(f.path)} + className={`git-vc-file-row ${isSelected ? "selected" : ""}`} + style={{ + display: "flex", + alignItems: "center", + gap: "var(--space-3)", + padding: "6px 10px", + borderRadius: "var(--radius-md)", + cursor: "pointer", + userSelect: "none", + background: isSelected ? "var(--surface-subtle)" : "transparent", + transition: "background var(--motion-fast)", + }} + > + togglePath(f.path)} + disabled={committing} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${f.path}`} + style={{ cursor: "pointer", accentColor: "var(--accent-solid, #6366f1)" }} + /> + {isUntracked ? ( + + ) : ( + + )} +
    + {dir && {dir}} + {file} +
    + + {isUntracked ? "NEW" : "MODIFIED"} + +
  • + ); + })} +
+ )} +
+
+
+ )}
); } -// ── Stashes Tab ─────────────────────────────────────────────────────────────── - -function StashesTab({ workspacePath, onNotify }) { - const [stashes, setStashes] = useState([]); - const [loading, setLoading] = useState(false); - const [stashMsg, setStashMsg] = useState(""); - const [saving, setSaving] = useState(false); - - const loadStashes = useCallback(async () => { - setLoading(true); - try { - const result = await gitStashList(workspacePath); - if (result?.ok) setStashes(result.data || []); - } catch { /* ignore */ } finally { - setLoading(false); - } - }, [workspacePath]); - - useEffect(() => { loadStashes(); }, [loadStashes]); - - async function handlePush() { - setSaving(true); - const result = await gitStashPush({ workspacePath, message: stashMsg.trim() || null }); - setSaving(false); - if (result?.ok) { - onNotify?.("Changes stashed.", "success"); - setStashMsg(""); - loadStashes(); - } else { - onNotify?.(result?.error || "Stash failed.", "error"); - } - } - - async function handlePop(index) { - const result = await gitStashPop({ workspacePath, index }); - if (result?.ok) { - onNotify?.("Stash applied.", "success"); - loadStashes(); - } else { - onNotify?.(result?.error || "Failed to apply stash.", "error"); - } - } - - async function handleDrop(index, message) { - if (!window.confirm(`Drop stash: "${message}"?`)) return; - const result = await gitStashDrop({ workspacePath, index }); - if (result?.ok) { - onNotify?.("Stash dropped.", "success"); - loadStashes(); - } else { - onNotify?.(result?.error || "Failed to drop stash.", "error"); - } - } +// ── Milestones / History Tab ────────────────────────────────────────────────── +function HistoryTab({ commits, loading, error }) { return ( -
-
-

Stash changes

-
- setStashMsg(e.target.value)} - placeholder="Stash message (optional)" - aria-label="Stash message" - /> - - - {saving ? "Stashing…" : "Stash"} - -
-
- -
-

Stashes ({stashes.length})

- {loading ? ( -
Loading stashes…
- ) : stashes.length === 0 ? ( -

No stashes.

- ) : ( -
    - {stashes.map((s) => ( -
  • -
  • - ))} -
- )} -
+
+
); } -// ── Remotes Tab ─────────────────────────────────────────────────────────────── +// ── Cloud Sync & Backup Tab ─────────────────────────────────────────────────── -function RemotesTab({ workspacePath, onNotify, status }) { +function SyncTab({ workspacePath, onNotify, status, onRefresh }) { const [remotes, setRemotes] = useState([]); const [loading, setLoading] = useState(false); const [newName, setNewName] = useState("origin"); @@ -779,7 +451,9 @@ function RemotesTab({ workspacePath, onNotify, status }) { } }, [workspacePath]); - useEffect(() => { loadRemotes(); }, [loadRemotes]); + useEffect(() => { + loadRemotes(); + }, [loadRemotes]); async function handleAdd() { if (!newName.trim() || !newUrl.trim()) return; @@ -787,28 +461,53 @@ function RemotesTab({ workspacePath, onNotify, status }) { const result = await gitAddRemote({ workspacePath, name: newName.trim(), url: newUrl.trim() }); setAdding(false); if (result?.ok) { - onNotify?.(`Remote "${newName}" added.`, "success"); + onNotify?.(`Cloud target "${newName}" configured.`, "success"); setNewName("origin"); setNewUrl(""); setNewToken(""); loadRemotes(); + onRefresh?.(); } else { onNotify?.(result?.error || "Failed to add remote.", "error"); } } async function handleRemove(name) { - if (!window.confirm(`Remove remote "${name}"?`)) return; + if (!window.confirm(`Remove remote target "${name}"?`)) return; const result = await gitRemoveRemote({ workspacePath, name }); if (result?.ok) { onNotify?.(`Remote "${name}" removed.`, "success"); loadRemotes(); + onRefresh?.(); } else { onNotify?.(result?.error || "Failed to remove remote.", "error"); } } - async function handleSync(remote, action) { + async function handleFullSync(remote) { + setSyncBusy(true); + const auth = newToken.trim() ? { type: "pat", token: newToken.trim() } : { type: "ssh" }; + try { + const pullRes = await gitPull({ workspacePath, remote: remote.name, auth }); + if (!pullRes?.ok) { + throw new Error(pullRes?.error || "Pull from cloud failed."); + } + + const pushRes = await gitPush({ workspacePath, remote: remote.name, auth }); + if (!pushRes?.ok) { + throw new Error(pushRes?.error || "Push to cloud failed."); + } + + onNotify?.("Workspace synced with cloud successfully.", "success"); + onRefresh?.(); + } catch (err) { + onNotify?.(err?.message || "Sync failed.", "error"); + } finally { + setSyncBusy(false); + } + } + + async function handleIndividualAction(remote, action) { setSyncBusy(true); const auth = newToken.trim() ? { type: "pat", token: newToken.trim() } : { type: "ssh" }; try { @@ -820,8 +519,10 @@ function RemotesTab({ workspacePath, onNotify, status }) { } else { result = await gitFetch({ workspacePath, remote: remote.name, auth }); } + if (result?.ok) { - onNotify?.(`${action.charAt(0).toUpperCase() + action.slice(1)} to "${remote.name}" succeeded.`, "success"); + onNotify?.(`${action.toUpperCase()} with "${remote.name}" succeeded.`, "success"); + onRefresh?.(); } else { onNotify?.(result?.error || `${action} failed.`, "error"); } @@ -835,19 +536,37 @@ function RemotesTab({ workspacePath, onNotify, status }) { const { ahead = 0, behind = 0 } = status || {}; return ( -
- {remotes.length > 0 && ( -
-

Sync

- {(ahead > 0 || behind > 0) && ( -

- {ahead > 0 && {ahead} commit{ahead === 1 ? "" : "s"} to push} - {behind > 0 && {behind} commit{behind === 1 ? "" : "s"} to pull} -

- )} +
+ {/* Cloud Sync Status Card */} + {remotes.length > 0 ? ( +
+
+

+ Cloud Backup & Sync +

+ {(ahead > 0 || behind > 0) ? ( +

+ {ahead > 0 && ↑ {ahead} to push} + {behind > 0 && ↓ {behind} to pull} +

+ ) : ( + + ✓ Up to date with cloud + + )} +
-
-