Compare commits
5 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db47ffe454 | ||
|
|
008f067bf7 | ||
|
|
55a2509658 | ||
|
|
790f2ea85c | ||
|
|
ed5e6d6820 |
@@ -23,29 +23,44 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { startUiBridge } from './ws-bridge.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
|
||||
// If the port is already in use (e.g. by the running Laputa app), continue
|
||||
// without the bridge — vault tools still work via stdio MCP.
|
||||
let uiBridge = null
|
||||
startUiBridge(WS_UI_PORT).then((bridge) => {
|
||||
uiBridge = bridge
|
||||
})
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
@@ -233,6 +248,7 @@ async function handleCreateNote(args) {
|
||||
const frontmatter = {}
|
||||
if (args.is_a) frontmatter.is_a = args.is_a
|
||||
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
|
||||
}
|
||||
|
||||
@@ -246,21 +262,25 @@ async function handleSearchNotes(args) {
|
||||
|
||||
async function handleAppendToNote(args) {
|
||||
await appendToNote(VAULT_PATH, args.path, args.text)
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleEditFrontmatter(args) {
|
||||
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
|
||||
}
|
||||
|
||||
async function handleDeleteNote(args) {
|
||||
await deleteNote(VAULT_PATH, args.path)
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleLinkNotes(args) {
|
||||
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
|
||||
broadcastUiAction('vault_changed', { path: args.source_path })
|
||||
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
|
||||
}
|
||||
|
||||
@@ -293,7 +313,7 @@ function handleUiHighlight(args) {
|
||||
}
|
||||
|
||||
function handleUiSetFilter(args) {
|
||||
broadcastUiAction('set_filter', { type: args.type })
|
||||
broadcastUiAction('set_filter', { filterType: args.type })
|
||||
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
|
||||
}
|
||||
|
||||
|
||||
@@ -50,18 +50,18 @@ function buildFrontmatter(args) {
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
|
||||
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)).then(r => { broadcastUiAction('vault_changed', { path: args.path }); return r }),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
|
||||
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
|
||||
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
|
||||
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
|
||||
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => { broadcastUiAction('vault_changed', { path: args.path }); return { ok: true } }),
|
||||
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch).then(r => { broadcastUiAction('vault_changed', { path: args.path }); return r }),
|
||||
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => { broadcastUiAction('vault_changed', { path: args.path }); return { ok: true } }),
|
||||
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title).then(r => { broadcastUiAction('vault_changed', { path: args.source_path }); return r }),
|
||||
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
@@ -101,8 +101,15 @@ export function startUiBridge(port = WS_UI_PORT) {
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
wss.on('connection', () => {
|
||||
wss.on('connection', (ws) => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
// Relay: when a client sends a message, broadcast to all OTHER clients.
|
||||
// This allows the MCP stdio server (connected as a client) to reach the frontend.
|
||||
ws.on('message', (raw) => {
|
||||
for (const client of wss.clients) {
|
||||
if (client !== ws && client.readyState === 1) client.send(raw.toString())
|
||||
}
|
||||
})
|
||||
})
|
||||
uiBridge = wss
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
|
||||
@@ -475,6 +475,12 @@ fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -670,7 +676,8 @@ pub fn run() {
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme,
|
||||
create_vault_theme
|
||||
create_vault_theme,
|
||||
ensure_vault_themes
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -145,17 +145,50 @@ pub fn seed_default_themes(vault_path: &str) {
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
/// Per-file idempotent: creates the directory if missing, writes each default
|
||||
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
|
||||
/// existing files that have content.
|
||||
pub fn seed_vault_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("theme"),
|
||||
&[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
],
|
||||
"Seeded theme/ with built-in vault themes",
|
||||
);
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
if fs::create_dir_all(&theme_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
];
|
||||
let mut seeded = false;
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, content);
|
||||
seeded = true;
|
||||
}
|
||||
}
|
||||
if seeded {
|
||||
log::info!("Seeded theme/ with built-in vault themes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure vault theme files exist. Returns an error if the theme directory
|
||||
/// cannot be created (e.g. read-only filesystem).
|
||||
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
];
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new vault theme note in `theme/` directory.
|
||||
@@ -856,4 +889,94 @@ mod tests {
|
||||
assert!(content.contains("accent-blue:"));
|
||||
assert!(content.contains("editor-font-size:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
// Only default exists — dark and minimal should be seeded
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
assert!(theme_dir.join("dark.md").exists());
|
||||
assert!(theme_dir.join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
// Create empty file — should be re-seeded
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_preserves_existing_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("#FF0000"),
|
||||
"existing content must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_creates_dir_and_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_preserves_custom_themes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("#123456"));
|
||||
}
|
||||
}
|
||||
|
||||
11
src/App.css
11
src/App.css
@@ -61,3 +61,14 @@
|
||||
.tab-status-pulse {
|
||||
animation: tab-status-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
|
||||
@keyframes ai-highlight-glow {
|
||||
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
|
||||
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
|
||||
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
|
||||
}
|
||||
|
||||
.ai-highlight {
|
||||
animation: ai-highlight-glow 0.8s ease-out;
|
||||
}
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -35,6 +35,7 @@ import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useNavigationGestures } from './hooks/useNavigationGestures'
|
||||
import { useAiActivity } from './hooks/useAiActivity'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
@@ -208,6 +209,22 @@ function App() {
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: (path) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
},
|
||||
onOpenTab: (path) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
},
|
||||
onSetFilter: (filterType) => {
|
||||
setSelection({ kind: 'sectionGroup', type: filterType })
|
||||
},
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
@@ -413,13 +430,13 @@ function App() {
|
||||
)}
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
)}
|
||||
<div className="app__editor">
|
||||
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
|
||||
<Editor
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
|
||||
@@ -165,7 +165,7 @@ function SortableSection({ group, sectionProps }: {
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
@@ -178,6 +178,7 @@ function SortableSection({ group, sectionProps }: {
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
onRenameSubmit={sectionProps.onRenameSubmit}
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface SectionContentProps {
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void
|
||||
@@ -93,7 +94,7 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
@@ -113,6 +114,7 @@ export function SectionContent({
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
@@ -182,11 +184,12 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
}) {
|
||||
@@ -194,6 +197,7 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
{...dragHandleProps}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
|
||||
@@ -7,6 +7,7 @@ let lastWsInstance: MockWebSocket | null = null
|
||||
class MockWebSocket {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
close = vi.fn()
|
||||
url: string
|
||||
|
||||
@@ -82,10 +83,10 @@ describe('useAiActivity', () => {
|
||||
expect(result.current.highlightElement).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores non-highlight messages', () => {
|
||||
it('ignores non-ui_action messages', () => {
|
||||
const { result } = renderHook(() => useAiActivity())
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'other_action' })
|
||||
sendWsMessage({ type: 'other', action: 'highlight', element: 'editor' })
|
||||
})
|
||||
expect(result.current.highlightElement).toBeNull()
|
||||
})
|
||||
@@ -112,4 +113,57 @@ describe('useAiActivity', () => {
|
||||
expect(result.current.highlightElement).toBe('properties')
|
||||
expect(result.current.highlightPath).toBeNull()
|
||||
})
|
||||
|
||||
it('calls onOpenNote callback on open_note action', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
renderHook(() => useAiActivity({ onOpenNote }))
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'open_note', path: 'project/foo.md' })
|
||||
})
|
||||
expect(onOpenNote).toHaveBeenCalledWith('project/foo.md')
|
||||
})
|
||||
|
||||
it('calls onOpenTab callback on open_tab action', () => {
|
||||
const onOpenTab = vi.fn()
|
||||
renderHook(() => useAiActivity({ onOpenTab }))
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'open_tab', path: 'note/bar.md' })
|
||||
})
|
||||
expect(onOpenTab).toHaveBeenCalledWith('note/bar.md')
|
||||
})
|
||||
|
||||
it('calls onSetFilter callback on set_filter action', () => {
|
||||
const onSetFilter = vi.fn()
|
||||
renderHook(() => useAiActivity({ onSetFilter }))
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'set_filter', filterType: 'Project' })
|
||||
})
|
||||
expect(onSetFilter).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('calls onVaultChanged callback on vault_changed action', () => {
|
||||
const onVaultChanged = vi.fn()
|
||||
renderHook(() => useAiActivity({ onVaultChanged }))
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'vault_changed', path: 'note/new.md' })
|
||||
})
|
||||
expect(onVaultChanged).toHaveBeenCalledWith('note/new.md')
|
||||
})
|
||||
|
||||
it('does not call onOpenNote when path is missing', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
renderHook(() => useAiActivity({ onOpenNote }))
|
||||
act(() => {
|
||||
sendWsMessage({ type: 'ui_action', action: 'open_note' })
|
||||
})
|
||||
expect(onOpenNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconnects on close after delay', () => {
|
||||
renderHook(() => useAiActivity())
|
||||
const firstWs = lastWsInstance
|
||||
act(() => { firstWs?.onclose?.() })
|
||||
act(() => { vi.advanceTimersByTime(3000) })
|
||||
expect(lastWsInstance).not.toBe(firstWs)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
export type HighlightElement = 'editor' | 'tab' | 'properties' | 'notelist' | null
|
||||
|
||||
@@ -7,58 +7,89 @@ export interface AiActivity {
|
||||
highlightPath: string | null
|
||||
}
|
||||
|
||||
export interface AiActivityCallbacks {
|
||||
onOpenNote?: (path: string) => void
|
||||
onOpenTab?: (path: string) => void
|
||||
onSetFilter?: (type: string) => void
|
||||
onVaultChanged?: (path?: string) => void
|
||||
}
|
||||
|
||||
const WS_UI_URL = 'ws://localhost:9711'
|
||||
const HIGHLIGHT_DURATION_MS = 800
|
||||
const RECONNECT_DELAY_MS = 3000
|
||||
|
||||
/**
|
||||
* Listens on the UI WebSocket bridge (port 9711) for highlight events
|
||||
* from the AI agent. Sets highlightElement for 800ms then auto-clears.
|
||||
* Listens on the UI WebSocket bridge (port 9711) for UI action events
|
||||
* from the MCP server. Handles highlight, open_note, open_tab, set_filter,
|
||||
* and vault_changed actions.
|
||||
*/
|
||||
export function useAiActivity(): AiActivity {
|
||||
export function useAiActivity(callbacks?: AiActivityCallbacks): AiActivity {
|
||||
const [highlightElement, setHighlightElement] = useState<HighlightElement>(null)
|
||||
const [highlightPath, setHighlightPath] = useState<string | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const callbacksRef = useRef(callbacks)
|
||||
useEffect(() => { callbacksRef.current = callbacks })
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string)
|
||||
if (data.type !== 'ui_action') return
|
||||
switch (data.action) {
|
||||
case 'highlight':
|
||||
setHighlightElement(data.element ?? null)
|
||||
setHighlightPath(data.path ?? null)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
setHighlightElement(null)
|
||||
setHighlightPath(null)
|
||||
}, HIGHLIGHT_DURATION_MS)
|
||||
break
|
||||
case 'open_note':
|
||||
if (data.path) callbacksRef.current?.onOpenNote?.(data.path)
|
||||
break
|
||||
case 'open_tab':
|
||||
if (data.path) callbacksRef.current?.onOpenTab?.(data.path)
|
||||
break
|
||||
case 'set_filter':
|
||||
if (data.filterType) callbacksRef.current?.onSetFilter?.(data.filterType)
|
||||
break
|
||||
case 'vault_changed':
|
||||
callbacksRef.current?.onVaultChanged?.(data.path)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors from malformed messages
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null = null
|
||||
let mounted = true
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_UI_URL)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mounted) return
|
||||
try {
|
||||
const data = JSON.parse(event.data as string)
|
||||
if (data.type === 'ui_action' && data.action === 'highlight') {
|
||||
setHighlightElement(data.element ?? null)
|
||||
setHighlightPath(data.path ?? null)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (mounted) {
|
||||
setHighlightElement(null)
|
||||
setHighlightPath(null)
|
||||
}
|
||||
}, HIGHLIGHT_DURATION_MS)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors from malformed messages
|
||||
function connect() {
|
||||
if (!mounted) return
|
||||
try {
|
||||
ws = new WebSocket(WS_UI_URL)
|
||||
ws.onmessage = handleMessage
|
||||
ws.onclose = () => {
|
||||
if (mounted) reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
|
||||
}
|
||||
ws.onerror = () => { /* Silent — bridge may not be running */ }
|
||||
} catch {
|
||||
if (mounted) reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// Silent — UI bridge may not be running
|
||||
}
|
||||
} catch {
|
||||
// WebSocket connection failed — bridge not available
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
ws?.close()
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [])
|
||||
}, [handleMessage])
|
||||
|
||||
return { highlightElement, highlightPath }
|
||||
}
|
||||
|
||||
@@ -478,4 +478,17 @@ describe('useThemeManager', () => {
|
||||
// Light theme isDark should be false (default state is false, so this is stable)
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('calls ensure_vault_themes on mount with vaultPath', async () => {
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('ensure_vault_themes', { vaultPath: '/vault' })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call ensure_vault_themes when vaultPath is null', async () => {
|
||||
renderHook(() => useThemeManager(null, entries, allContent))
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,6 +196,11 @@ export function useThemeManager(
|
||||
entries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): ThemeManager {
|
||||
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
|
||||
useEffect(() => {
|
||||
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
|
||||
}, [vaultPath])
|
||||
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
|
||||
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
|
||||
@@ -309,6 +309,7 @@ line-height-base: 1.6
|
||||
syncWindowContent()
|
||||
return path
|
||||
},
|
||||
ensure_vault_themes: (): null => null,
|
||||
}
|
||||
|
||||
export function addMockEntry(_entry: VaultEntry, content: string): void {
|
||||
|
||||
@@ -2,6 +2,13 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
import { createElement, type ReactNode, type ComponentType } from 'react'
|
||||
|
||||
// Suppress undici WebSocket ERR_INVALID_ARG_TYPE in jsdom (jsdom Event ≠ Node Event)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(globalThis as any).process?.on?.('uncaughtException', (err: Error & { code?: string }) => {
|
||||
if (err.code === 'ERR_INVALID_ARG_TYPE' && err.message?.includes('Event')) return
|
||||
throw err
|
||||
})
|
||||
|
||||
// Mock scrollIntoView for jsdom (not implemented)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user