lorehub
Version:
Capture and surface the collective wisdom of your codebase
320 lines • 16.5 kB
JavaScript
import React, { useState, useEffect } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import SelectInput from 'ink-select-input';
import TextInput from 'ink-text-input';
import { Help } from './Help.js';
import { ConfirmDialog } from './ConfirmDialog.js';
import { SimilarFactsView } from './SimilarFactsView.js';
import { TruncatedText } from './TruncatedText.js';
import { AlternativeScreenView } from './AlternativeScreenView.js';
import { useTerminalDimensions } from '../hooks/useTerminalDimensions.js';
export function FactsView({ db, projectPath, initialQuery = '', type, province, limit = 100, filterProjectPath, currentProjectOnly, }) {
const { exit } = useApp();
const { columns, rows } = useTerminalDimensions();
const [facts, setFacts] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filteredFacts, setFilteredFacts] = useState([]);
const [showHelp, setShowHelp] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteSuccess, setDeleteSuccess] = useState(false);
const [similarFactsCounts, setSimilarFactsCounts] = useState(new Map());
const [loadingSimilarCounts, setLoadingSimilarCounts] = useState(false);
const [showSimilarFacts, setShowSimilarFacts] = useState(false);
useInput((input, key) => {
if (showHelp) {
if (input === '?' || key.escape) {
setShowHelp(false);
}
return;
}
if (showDeleteConfirm) {
// Let ConfirmDialog handle input
return;
}
if (isSearching) {
if (key.escape) {
setIsSearching(false);
setSearchTerm('');
setFilteredFacts(facts);
}
else if (key.return) {
setIsSearching(false);
}
else if (key.backspace || key.delete) {
const newSearchTerm = searchTerm.slice(0, -1);
setSearchTerm(newSearchTerm);
filterFacts(newSearchTerm);
}
else if (input && input.length === 1 && !key.ctrl && !key.meta) {
const newSearchTerm = searchTerm + input;
setSearchTerm(newSearchTerm);
filterFacts(newSearchTerm);
}
}
else {
if (input === 'q' || key.escape) {
exit();
}
else if (input === '/') {
setIsSearching(true);
setSearchTerm('');
}
else if (input === '?') {
setShowHelp(true);
}
else if (input === 'd' && filteredFacts.length > 0) {
setShowDeleteConfirm(true);
}
else if (input === 's' && filteredFacts.length > 0) {
setShowSimilarFacts(true);
}
}
});
const filterFacts = (term) => {
if (!term) {
setFilteredFacts(facts);
return;
}
const lowerTerm = term.toLowerCase();
const filtered = facts.filter(fact => fact.content.toLowerCase().includes(lowerTerm) ||
fact.type.toLowerCase().includes(lowerTerm) ||
fact.sigils.some(sigil => sigil.toLowerCase().includes(lowerTerm)) ||
fact.projectName.toLowerCase().includes(lowerTerm));
setFilteredFacts(filtered);
setSelectedIndex(0);
};
useEffect(() => {
const currentProject = db.findProjectByPath(projectPath);
const projects = db.listProjects();
// Filter projects based on options
let projectsToSearch = projects;
if (currentProjectOnly) {
if (!currentProject) {
setFacts([]);
setLoading(false);
return;
}
projectsToSearch = [currentProject];
}
else if (filterProjectPath) {
const specificProject = db.findProjectByPath(filterProjectPath);
if (!specificProject) {
setFacts([]);
setLoading(false);
return;
}
projectsToSearch = [specificProject];
}
let results = [];
// Get facts from selected projects
for (const proj of projectsToSearch) {
let projectFacts = [];
if (initialQuery) {
// Search mode
projectFacts = db.searchFacts(proj.id, initialQuery);
}
else if (type) {
projectFacts = db.listFactsByType(proj.id, type);
}
else if (province) {
projectFacts = db.listFactsByService(proj.id, province);
}
else {
projectFacts = db.listFactsByProject(proj.id);
}
// Add project info to each fact
results.push(...projectFacts.map(f => ({
...f,
projectName: proj.name,
projectPath: proj.path,
isCurrentProject: currentProject?.id === proj.id
})));
}
// Apply additional filters
if (initialQuery && type) {
results = results.filter(f => f.type === type);
}
if (initialQuery && province) {
results = results.filter(f => f.provinces.includes(province));
}
// Filter out archived facts unless specifically requested
results = results.filter(f => f.status !== 'archived');
// Sort by current project first, then by date
results.sort((a, b) => {
// Prioritize current project
if (a.isCurrentProject && !b.isCurrentProject)
return -1;
if (!a.isCurrentProject && b.isCurrentProject)
return 1;
// Then by date
return b.createdAt.getTime() - a.createdAt.getTime();
});
// Apply limit
if (results.length > limit) {
results = results.slice(0, limit);
}
setFacts(results);
setFilteredFacts(results);
setLoading(false);
}, [db, projectPath, initialQuery, type, province, limit, filterProjectPath, currentProjectOnly]);
// Load similar facts counts
useEffect(() => {
const loadSimilarCounts = async () => {
if (facts.length === 0)
return;
setLoadingSimilarCounts(true);
const counts = new Map();
// Load counts in batches to avoid overwhelming the system
const batchSize = 10;
for (let i = 0; i < facts.length && i < limit; i += batchSize) {
const batch = facts.slice(i, i + batchSize);
await Promise.all(batch.map(async (fact) => {
try {
const similar = await db.findSimilarFacts(fact.id, {
limit: 10,
threshold: 0.5
});
counts.set(fact.id, similar.length);
}
catch (error) {
counts.set(fact.id, 0);
}
}));
}
setSimilarFactsCounts(counts);
setLoadingSimilarCounts(false);
};
loadSimilarCounts();
}, [facts, db, limit]);
if (loading) {
return React.createElement(Text, null, "Loading facts...");
}
if (facts.length === 0) {
return (React.createElement(Box, { flexDirection: "column" },
React.createElement(Text, { color: "yellow" }, initialQuery
? `No facts found matching "${initialQuery}"`
: 'No facts found'),
type && React.createElement(Text, { dimColor: true },
"Filter: type = ",
type),
province && React.createElement(Text, { dimColor: true },
"Filter: service = ",
province),
!initialQuery && (React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { dimColor: true }, "Try adding facts with: lh add \"Your fact here\"")))));
}
// Calculate dimensions early so we can use them in items mapping
const headerHeight = 3;
const footerHeight = 2;
const contentHeight = rows - headerHeight - footerHeight - 1; // -1 for padding
// Split width: 60% for facts list, 40% for details on wide screens
// On narrow screens, use 50/50 split
const factsWidth = columns > 120 ? Math.floor(columns * 0.6) : Math.floor(columns * 0.5);
const detailsWidth = columns - factsWidth - 3; // -3 for margins and borders
const items = filteredFacts.map((fact, index) => {
const typeStr = `[${fact.type.substring(0, 3).toUpperCase()}]`;
const similarCount = similarFactsCounts.get(fact.id) || 0;
// Use fixed-width formatting
// • for current project (instead of star for predictable width)
// Fixed width similarity count (3 chars + ≈)
const projectMarker = fact.isCurrentProject ? '•' : ' ';
const similarStr = similarCount > 0 ? `${similarCount.toString().padStart(3)}≈` : ' ';
// Calculate available width for content
// Account for: project marker (1), space (1), similar count (4), space (1), type (5), space (1)
const prefixLength = 1 + 1 + 4 + 1 + 5 + 1;
const availableWidth = factsWidth - prefixLength - 4; // -4 for padding/margins
const contentMaxLength = Math.max(20, availableWidth); // minimum 20 chars
const content = fact.content.length > contentMaxLength
? fact.content.substring(0, contentMaxLength - 3) + '...'
: fact.content;
return {
label: `${projectMarker} ${similarStr} ${typeStr} ${content}`,
value: index,
};
});
const selectedFact = filteredFacts[selectedIndex];
// Handle selection changes
const handleSelect = (item) => {
setSelectedIndex(item.value);
};
const handleDelete = () => {
const factToDelete = filteredFacts[selectedIndex];
if (factToDelete) {
db.softDeleteFact(factToDelete.id);
// Remove from local state
const newFacts = facts.filter(f => f.id !== factToDelete.id);
const newFilteredFacts = filteredFacts.filter(f => f.id !== factToDelete.id);
setFacts(newFacts);
setFilteredFacts(newFilteredFacts);
setShowDeleteConfirm(false);
setDeleteSuccess(true);
// Reset success message after 2 seconds
setTimeout(() => setDeleteSuccess(false), 2000);
// Adjust selected index if needed
if (selectedIndex >= newFilteredFacts.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
}
};
// Show help screen if requested
if (showHelp) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(Help, { context: initialQuery ? 'search' : 'list' })));
}
// Show confirmation dialog instead of normal view
if (showDeleteConfirm && selectedFact) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(Box, { flexDirection: "column", height: 20, justifyContent: "center", alignItems: "center" },
React.createElement(ConfirmDialog, { message: `Delete fact: "${selectedFact.content.substring(0, 50)}${selectedFact.content.length > 50 ? '...' : ''}"?`, onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false), dangerous: true }))));
}
// Show similar facts view
if (showSimilarFacts && selectedFact) {
return (React.createElement(AlternativeScreenView, null,
React.createElement(SimilarFactsView, { db: db, fact: selectedFact, onBack: () => setShowSimilarFacts(false) })));
}
// Full screen container
return (React.createElement(AlternativeScreenView, null,
React.createElement(Box, { flexDirection: "column", height: rows - 1 },
React.createElement(Box, { height: 3, flexDirection: "column" },
React.createElement(Text, { bold: true },
initialQuery
? `Found ${filteredFacts.length} fact${filteredFacts.length !== 1 ? 's' : ''} matching "${initialQuery}"`
: `Found ${filteredFacts.length} fact${filteredFacts.length !== 1 ? 's' : ''}`,
searchTerm && ` (filtered: "${searchTerm}")`),
deleteSuccess && React.createElement(Text, { color: "green" }, "\u2713 Fact deleted (archived)"),
(type || province) && (React.createElement(Text, { dimColor: true },
"Filters: ",
[
type && `type=${type}`,
province && `province=${province}`
].filter(Boolean).join(', '))),
isSearching && (React.createElement(Box, null,
React.createElement(Text, { color: "cyan" }, "/"),
React.createElement(TextInput, { value: searchTerm, onChange: () => { } })))),
React.createElement(Box, { flexDirection: "row", height: contentHeight, overflow: "hidden" },
React.createElement(Box, { flexDirection: "column", width: factsWidth, marginRight: 2 },
React.createElement(Text, { bold: true, dimColor: true }, initialQuery ? 'Results' : 'Facts'),
React.createElement(Box, { marginTop: 1, width: "100%" },
React.createElement(SelectInput, { items: items, onSelect: handleSelect, onHighlight: handleSelect, initialIndex: 0, limit: contentHeight - 2 }))),
React.createElement(Box, { flexDirection: "column", width: detailsWidth, overflow: "hidden" },
React.createElement(Text, { bold: true, dimColor: true }, "Details"),
selectedFact && (React.createElement(Box, { flexDirection: "column", marginTop: 1, height: contentHeight - 2, overflow: "hidden" },
React.createElement(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden" },
React.createElement(TruncatedText, { text: selectedFact.content, maxLines: selectedFact.why ? Math.floor((contentHeight - 10) * 0.6) : contentHeight - 10, width: detailsWidth }),
selectedFact.why && (React.createElement(Box, { marginTop: 1, flexDirection: "column" },
React.createElement(TruncatedText, { text: `Why: ${selectedFact.why}`, maxLines: Math.floor((contentHeight - 10) * 0.4), width: detailsWidth, dimColor: true })))),
React.createElement(Box, { flexDirection: "column", flexShrink: 0, marginTop: 1 },
React.createElement(TruncatedText, { text: `Project: ${selectedFact.projectName}${selectedFact.isCurrentProject ? ' •' : ''}`, maxLines: 1, width: detailsWidth, dimColor: true }),
React.createElement(TruncatedText, { text: `Type: ${selectedFact.type} | Status: ${selectedFact.status}`, maxLines: 1, width: detailsWidth, dimColor: true }),
React.createElement(TruncatedText, { text: `Confidence: ${selectedFact.confidence}% | ${selectedFact.createdAt.toLocaleDateString()}`, maxLines: 1, width: detailsWidth, dimColor: true }),
selectedFact.sigils.length > 0 && (React.createElement(TruncatedText, { text: `Sigils: ${selectedFact.sigils.join(', ')}`, maxLines: 1, width: detailsWidth, dimColor: true })),
selectedFact.provinces.length > 0 && (React.createElement(TruncatedText, { text: `Provinces: ${selectedFact.provinces.join(', ')}`, maxLines: 1, width: detailsWidth, dimColor: true }))))))),
React.createElement(Box, { height: 2, marginTop: 1 },
React.createElement(Text, { dimColor: true }, isSearching
? 'Type to filter | Enter: confirm | Esc: cancel'
: 'q: quit | ↑↓: navigate | /: filter | d: delete | s: similar | ?: help')))));
}
//# sourceMappingURL=FactsView.js.map