* notes history
This commit is contained in:
arkml 2026-02-27 20:22:54 +05:30 committed by GitHub
parent 9df1bb6765
commit d7dc27a77e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 860 additions and 40 deletions

View file

@ -31,6 +31,7 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js';
import { versionHistory } from '@x/core';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -105,6 +106,18 @@ let watcher: FSWatcher | null = null;
const changeQueue = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Emit knowledge commit event to all renderer windows
*/
function emitKnowledgeCommitEvent(): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('knowledge:didCommit', {});
}
}
}
/**
* Emit workspace change event to all renderer windows
*/
@ -283,6 +296,9 @@ export function stopServicesWatcher(): void {
* Add new handlers here as you add channels to IPCChannels
*/
export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@ -498,6 +514,19 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size };
},
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
return { commits };
},
'knowledge:fileAtCommit': async (_event, args) => {
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
return { content };
},
'knowledge:restore': async (_event, args) => {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
// Search handler
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);

View file

@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -49,6 +49,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
import { OnboardingModal } from '@/components/onboarding-modal'
import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
@ -506,6 +507,13 @@ function App() {
const initialContentRef = useRef<string>('')
const renameInProgressRef = useRef(false)
// Version history state
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
oid: string
content: string
} | null>(null)
// Chat state
const [, setMessage] = useState<string>('')
const [conversation, setConversation] = useState<ConversationItem[]>([])
@ -1072,6 +1080,14 @@ function App() {
saveFile()
}, [debouncedContent, setHistory])
// Close version history panel when switching files
useEffect(() => {
if (versionHistoryPath && selectedPath !== versionHistoryPath) {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
}
}, [selectedPath, versionHistoryPath])
// Load runs list (all pages)
const loadRuns = useCallback(async () => {
try {
@ -3213,6 +3229,31 @@ function App() {
) : null}
</div>
)}
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (versionHistoryPath) {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
} else {
setVersionHistoryPath(selectedPath)
}
}}
className={cn(
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
versionHistoryPath && "bg-accent text-foreground"
)}
aria-label="Version history"
>
<HistoryIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !selectedTask && (
<Tooltip>
<TooltipTrigger asChild>
@ -3276,41 +3317,80 @@ function App() {
</div>
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{openMarkdownTabs.map((tab) => {
const isActive = activeFileTabId
? tab.id === activeFileTabId || tab.path === selectedPath
: tab.path === selectedPath
const tabContent = editorContentByPath[tab.path]
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
return (
<div
key={tab.id}
className={cn(
'min-h-0 flex-1 flex-col overflow-hidden',
isActive ? 'flex' : 'hidden'
)}
data-file-tab-panel={tab.id}
aria-hidden={!isActive}
>
<MarkdownEditor
content={tabContent}
onChange={(markdown) => handleEditorChange(tab.path, markdown)}
placeholder="Start writing..."
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
onHistoryHandlersChange={(handlers) => {
if (handlers) {
fileHistoryHandlersRef.current.set(tab.id, handlers)
} else {
fileHistoryHandlersRef.current.delete(tab.id)
}
}}
/>
</div>
)
})}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{openMarkdownTabs.map((tab) => {
const isActive = activeFileTabId
? tab.id === activeFileTabId || tab.path === selectedPath
: tab.path === selectedPath
const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path
const tabContent = isViewingHistory
? viewingHistoricalVersion.content
: editorContentByPath[tab.path]
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
return (
<div
key={tab.id}
className={cn(
'min-h-0 flex-1 flex-col overflow-hidden',
isActive ? 'flex' : 'hidden'
)}
data-file-tab-panel={tab.id}
aria-hidden={!isActive}
>
<MarkdownEditor
content={tabContent}
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
placeholder="Start writing..."
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
onHistoryHandlersChange={(handlers) => {
if (handlers) {
fileHistoryHandlersRef.current.set(tab.id, handlers)
} else {
fileHistoryHandlersRef.current.delete(tab.id)
}
}}
editable={!isViewingHistory}
/>
</div>
)
})}
</div>
{versionHistoryPath && (
<VersionHistoryPanel
path={versionHistoryPath}
onClose={() => {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
}}
onSelectVersion={(oid, content) => {
if (oid === null) {
setViewingHistoricalVersion(null)
} else {
setViewingHistoricalVersion({ oid, content })
}
}}
onRestore={async (oid) => {
try {
await window.ipc.invoke('knowledge:restore', {
path: versionHistoryPath.startsWith('knowledge/')
? versionHistoryPath.slice('knowledge/'.length)
: versionHistoryPath,
oid,
})
// Reload file content
const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath })
handleEditorChange(versionHistoryPath, result.data)
setViewingHistoricalVersion(null)
setVersionHistoryPath(null)
} catch (err) {
console.error('Failed to restore version:', err)
}
}}
/>
)}
</div>
) : (
<div className="flex-1 overflow-auto p-4">

View file

@ -197,6 +197,7 @@ interface MarkdownEditorProps {
onImageUpload?: (file: File) => Promise<string | null>
editorSessionKey?: number
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
editable?: boolean
}
type WikiLinkMatch = {
@ -282,6 +283,7 @@ export function MarkdownEditor({
onImageUpload,
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -303,6 +305,7 @@ export function MarkdownEditor({
)
const editor = useEditor({
editable,
extensions: [
StarterKit.configure({
heading: {
@ -517,6 +520,13 @@ export function MarkdownEditor({
}
}, [editor, onHistoryHandlersChange])
// Update editable state when prop changes
useEffect(() => {
if (editor) {
editor.setEditable(editable)
}
}, [editor, editable])
// Force re-render decorations when selection highlight changes
useEffect(() => {
if (editor) {

View file

@ -0,0 +1,177 @@
import { useEffect, useState, useCallback } from 'react'
import { X, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface CommitInfo {
oid: string
message: string
timestamp: number
author: string
}
interface VersionHistoryPanelProps {
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
onClose: () => void
onSelectVersion: (oid: string | null, content: string) => void // null = current
onRestore: (oid: string) => void
}
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
const d = new Date(unixSeconds * 1000)
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
return { date, time }
}
export function VersionHistoryPanel({
path,
onClose,
onSelectVersion,
onRestore,
}: VersionHistoryPanelProps) {
const [commits, setCommits] = useState<CommitInfo[]>([])
const [loading, setLoading] = useState(true)
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
const [error, setError] = useState<string | null>(null)
// Strip "knowledge/" prefix for IPC calls
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
const loadHistory = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
setCommits(result.commits)
} catch (err) {
console.error('Failed to load version history:', err)
setError('Failed to load history')
} finally {
setLoading(false)
}
}, [relPath])
useEffect(() => {
loadHistory()
}, [loadHistory])
// Refresh when new commits land
useEffect(() => {
return window.ipc.on('knowledge:didCommit', () => {
loadHistory()
})
}, [loadHistory])
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
if (isLatest) {
setSelectedOid(null)
// Read current file content
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
onSelectVersion(null, result.data)
} catch (err) {
console.error('Failed to read current file:', err)
}
return
}
setSelectedOid(oid)
try {
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
onSelectVersion(oid, result.content)
} catch (err) {
console.error('Failed to load file at commit:', err)
}
}, [path, relPath, onSelectVersion])
const handleRestore = useCallback(() => {
if (selectedOid) {
onRestore(selectedOid)
}
}, [selectedOid, onRestore])
return (
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">Version history</span>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Close version history"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Commit list */}
<div className="flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
{error}
</div>
) : commits.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No history available
</div>
) : (
<div className="py-1">
{commits.map((commit, index) => {
const isLatest = index === 0
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
const { date, time } = formatTimestamp(commit.timestamp)
return (
<button
key={commit.oid}
type="button"
onClick={() => handleSelectCommit(commit.oid, isLatest)}
className={cn(
'w-full text-left px-3 py-2 transition-colors',
isSelected
? 'bg-accent'
: 'hover:bg-accent/50'
)}
>
<div className="flex items-center gap-1.5">
{!isLatest && (
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="text-sm text-foreground">
{date} &middot; {time}
</span>
</div>
{isLatest && (
<div className="text-xs text-muted-foreground mt-0.5">
Current version
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Footer */}
{selectedOid && (
<div className="shrink-0 border-t border-border p-3">
<Button
variant="default"
size="sm"
className="w-full"
onClick={handleRestore}
>
Restore this version
</Button>
</div>
)}
</div>
)
}