feat: add Pulse — vault activity feed showing git commit history

Adds a new Pulse sidebar section that shows chronological git commit
history for the vault. Commits are grouped by day with message, time,
short hash (clickable GitHub link when remote configured), file list
with add/modify/delete status icons, and summary badges. Clicking a
file opens the note in the editor. Disabled with tooltip for non-git
vaults. Accessible via sidebar click or "Go to Pulse" command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-05 16:27:29 +01:00
parent 3431b794d1
commit 559c585a08
13 changed files with 913 additions and 40326 deletions

View File

@@ -587,6 +587,163 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
Ok(format!("{}{}", stdout, stderr))
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
pub path: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub date: i64,
#[serde(rename = "githubUrl")]
pub github_url: Option<String>,
pub files: Vec<PulseFile>,
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
fn title_from_path(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.strip_suffix(".md")
.unwrap_or(path)
.replace('-', " ")
}
fn parse_file_status(code: &str) -> &str {
match code {
"A" => "added",
"M" => "modified",
"D" => "deleted",
_ => "modified",
}
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
return Err("Not a git repository".to_string());
}
let limit_str = limit.to_string();
let output = Command::new("git")
.args([
"log",
"--name-status",
"--pretty=format:%H|%h|%s|%aI",
"--diff-filter=ADM",
"-n",
&limit_str,
"--",
"*.md",
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let github_base = get_github_base_url(vault_path);
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pulse_output(&stdout, &github_base))
}
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!("https://github.com/{}", repo_path))
}
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
let mut commits: Vec<PulseCommit> = Vec::new();
let mut current: Option<PulseCommit> = None;
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if line.contains('|') && !line.starts_with(|c: char| c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')) {
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
}
}
if let Some(commit) = current {
commits.push(commit);
}
commits
}
#[derive(Debug, Serialize, Clone)]
pub struct LastCommitInfo {
#[serde(rename = "shortHash")]
@@ -1560,4 +1717,182 @@ mod tests {
// After resolution, mode should be none
assert_eq!(get_conflict_mode(vp_b), "none");
}
#[test]
fn test_get_vault_pulse_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
assert_eq!(pulse[1].message, "Add note");
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "project.md");
assert_eq!(pulse[0].files[0].status, "added");
assert_eq!(pulse[0].added, 1);
assert_eq!(pulse[0].modified, 0);
assert!(!pulse[0].short_hash.is_empty());
}
#[test]
fn test_get_vault_pulse_no_git() {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
#[test]
fn test_get_vault_pulse_empty_repo() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse.is_empty());
}
#[test]
fn test_get_vault_pulse_only_md_files() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
}
#[test]
fn test_get_vault_pulse_respects_limit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
for i in 0..5 {
fs::write(vault.join(format!("note{}.md", i)), format!("# Note {}\n", i)).unwrap();
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3).unwrap();
assert_eq!(pulse.len(), 3);
}
#[test]
fn test_get_vault_pulse_modified_and_deleted() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
}
#[test]
fn test_get_vault_pulse_github_url() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(vault)
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_some());
let url = pulse[0].github_url.as_ref().unwrap();
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
}
#[test]
fn test_get_vault_pulse_no_github_url_without_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_none());
}
#[test]
fn test_title_from_path() {
assert_eq!(title_from_path("note/my-project.md"), "my project");
assert_eq!(title_from_path("simple.md"), "simple");
assert_eq!(title_from_path("deep/nested/file.md"), "file");
}
#[test]
fn test_parse_pulse_output_basic() {
let stdout = "abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add notes");
assert_eq!(commits[0].files.len(), 2);
assert_eq!(commits[0].files[0].status, "added");
assert_eq!(commits[0].files[1].status, "modified");
assert_eq!(commits[0].added, 1);
assert_eq!(commits[0].modified, 1);
assert!(commits[0].github_url.is_none());
}
#[test]
fn test_parse_pulse_output_with_github() {
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
let base = Some("https://github.com/o/r".to_string());
let commits = parse_pulse_output(stdout, &base);
assert_eq!(
commits[0].github_url.as_deref(),
Some("https://github.com/o/r/commit/abc123")
);
}
#[test]
fn test_parse_pulse_output_multiple_commits() {
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "First");
assert_eq!(commits[1].message, "Second");
assert_eq!(commits[1].files.len(), 2);
assert_eq!(commits[1].deleted, 1);
}
}

View File

@@ -21,7 +21,7 @@ use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use indexing::{IndexStatus, IndexingProgress};
use search::SearchResponse;
@@ -112,6 +112,13 @@ fn get_file_diff_at_commit(
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn get_vault_pulse(vault_path: String, limit: Option<usize>) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(30);
git::get_vault_pulse(&vault_path, limit)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -683,6 +690,7 @@ pub fn run() {
get_modified_files,
get_file_diff,
get_file_diff_at_commit,
get_vault_pulse,
git_commit,
get_build_number,
get_last_commit_info,

View File

@@ -9,6 +9,7 @@ import { CommandPalette } from './components/CommandPalette'
import { SearchPanel } from './components/SearchPanel'
import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
@@ -491,7 +492,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -499,7 +500,15 @@ function App() {
{noteListVisible && (
<>
<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} />
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
if (entry) notes.handleSelectNote(entry)
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<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} />
</>

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PulseView } from './PulseView'
import type { PulseCommit } from '../types'
const mockCommits: PulseCommit[] = [
{
hash: 'abc123def456',
shortHash: 'abc123d',
message: 'Update project notes',
date: Math.floor(Date.now() / 1000) - 3600,
githubUrl: 'https://github.com/owner/repo/commit/abc123def456',
files: [
{ path: 'project/my-project.md', status: 'modified', title: 'my project' },
{ path: 'note/new-note.md', status: 'added', title: 'new note' },
],
added: 1,
modified: 1,
deleted: 0,
},
{
hash: 'def456abc789',
shortHash: 'def456a',
message: 'Remove old notes',
date: Math.floor(Date.now() / 1000) - 86400,
githubUrl: null,
files: [
{ path: 'note/old.md', status: 'deleted', title: 'old' },
],
added: 0,
modified: 0,
deleted: 1,
},
]
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
describe('PulseView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows loading state initially', () => {
mockInvokeFn.mockReturnValue(new Promise(() => {})) // never resolves
render(<PulseView vaultPath="/test/vault" />)
expect(screen.getByText('Loading activity…')).toBeInTheDocument()
})
it('renders commits grouped by day', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Update project notes')).toBeInTheDocument()
})
expect(screen.getByText('Remove old notes')).toBeInTheDocument()
})
it('shows summary badges for added/modified/deleted', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('+1')).toBeInTheDocument()
})
expect(screen.getByText('~1')).toBeInTheDocument()
expect(screen.getByText('-1')).toBeInTheDocument()
})
it('shows commit hashes', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('abc123d')).toBeInTheDocument()
})
expect(screen.getByText('def456a')).toBeInTheDocument()
})
it('renders GitHub links for commits with githubUrl', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
const link = screen.getByText('abc123d')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('href', 'https://github.com/owner/repo/commit/abc123def456')
})
// Non-GitHub commit should be a span
const nonLink = screen.getByText('def456a')
expect(nonLink.tagName).toBe('SPAN')
})
it('renders file list with correct titles', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
expect(screen.getByText('new note')).toBeInTheDocument()
expect(screen.getByText('old')).toBeInTheDocument()
})
it('calls onOpenNote when clicking a non-deleted file', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
const onOpenNote = vi.fn()
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('my project'))
expect(onOpenNote).toHaveBeenCalledWith('project/my-project.md')
})
it('does not call onOpenNote when clicking a deleted file', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
const onOpenNote = vi.fn()
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
await waitFor(() => {
expect(screen.getByText('old')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('old'))
expect(onOpenNote).not.toHaveBeenCalled()
})
it('shows empty state when no commits', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('No activity yet')).toBeInTheDocument()
})
})
it('shows error state and retry button', async () => {
mockInvokeFn.mockRejectedValue('Not a git repository')
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Not a git repository')).toBeInTheDocument()
})
expect(screen.getByText('Retry')).toBeInTheDocument()
})
it('shows Load more button when hasMore is true', async () => {
const manyCommits = Array.from({ length: 30 }, (_, i) => ({
...mockCommits[0],
hash: `hash${i}`,
shortHash: `h${i}`,
message: `Commit ${i}`,
}))
mockInvokeFn.mockResolvedValue(manyCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Load more')).toBeInTheDocument()
})
})
it('calls get_vault_pulse with correct arguments', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/my/vault" />)
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 30 })
})
})
it('toggles file list visibility when clicking collapse button', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
// Click the collapse button on the first commit card
const collapseBtn = screen.getAllByLabelText('Collapse files')[0]
fireEvent.click(collapseBtn)
// Files should be hidden
expect(screen.queryByText('my project')).not.toBeInTheDocument()
})
it('renders Pulse header', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Pulse')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,297 @@
import { useState, useEffect, useCallback, memo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { PulseCommit, PulseFile } from '../types'
import { relativeDate } from '../utils/noteListHelpers'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
} from '@phosphor-icons/react'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
interface PulseViewProps {
vaultPath: string
onOpenNote?: (relativePath: string) => void
sidebarCollapsed?: boolean
onExpandSidebar?: () => void
}
function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
const groups = new Map<string, PulseCommit[]>()
for (const commit of commits) {
const date = new Date(commit.date * 1000)
const key = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const existing = groups.get(key)
if (existing) {
existing.push(commit)
} else {
groups.set(key, [commit])
}
}
return groups
}
function isToday(dateKey: string): boolean {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === today
}
function isYesterday(dateKey: string): boolean {
const yesterday = new Date(Date.now() - 86400000)
.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === yesterday
}
function formatDayLabel(dateKey: string): string {
if (isToday(dateKey)) return 'Today'
if (isYesterday(dateKey)) return 'Yesterday'
return dateKey
}
const STATUS_ICON = {
added: Plus,
modified: PencilSimple,
deleted: Minus,
} as const
const STATUS_COLOR = {
added: 'var(--accent-green, #16a34a)',
modified: 'var(--accent-orange, #ea580c)',
deleted: 'var(--destructive, #dc2626)',
} as const
function SummaryBadges({ added, modified, deleted }: { added: number; modified: number; deleted: number }) {
return (
<div className="flex items-center" style={{ gap: 8 }}>
{added > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.added }}>+{added}</span>}
{modified > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.modified }}>~{modified}</span>}
{deleted > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.deleted }}>-{deleted}</span>}
</div>
)
}
function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: string) => void }) {
const Icon = STATUS_ICON[file.status] ?? FileText
const color = STATUS_COLOR[file.status] ?? 'var(--muted-foreground)'
const isDeleted = file.status === 'deleted'
return (
<div
className={`flex items-center rounded transition-colors ${isDeleted ? '' : 'cursor-pointer hover:bg-accent'}`}
style={{ gap: 6, padding: '3px 8px' }}
onClick={isDeleted ? undefined : () => onOpenNote?.(file.path)}
title={file.path}
>
<Icon size={12} style={{ color, flexShrink: 0 }} weight="bold" />
<span
className={`truncate text-[12px] ${isDeleted ? 'text-muted-foreground line-through' : 'text-foreground'}`}
>
{file.title}
</span>
</div>
)
}
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
const [expanded, setExpanded] = useState(true)
const Chevron = expanded ? CaretDown : CaretRight
return (
<div className="border-b border-border" style={{ padding: '10px 16px' }}>
<div className="flex items-start justify-between" style={{ gap: 8 }}>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
<GitCommit size={13} className="text-muted-foreground" style={{ flexShrink: 0 }} />
<span className="truncate text-[13px] font-medium text-foreground">{commit.message}</span>
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{commit.githubUrl ? (
<a
className="flex items-center text-[11px] font-mono text-primary no-underline hover:underline"
style={{ gap: 3 }}
href={commit.githubUrl}
onClick={(e) => {
e.preventDefault()
if (isTauri()) {
import('@tauri-apps/plugin-opener').then((mod) => mod.openUrl(commit.githubUrl!))
} else {
window.open(commit.githubUrl!, '_blank')
}
}}
title="Open on GitHub"
>
{commit.shortHash}
<ArrowSquareOut size={10} />
</a>
) : (
<span className="text-[11px] font-mono text-muted-foreground">{commit.shortHash}</span>
)}
<SummaryBadges added={commit.added} modified={commit.modified} deleted={commit.deleted} />
</div>
</div>
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? 'Collapse files' : 'Expand files'}
>
<Chevron size={12} />
</button>
</div>
{expanded && commit.files.length > 0 && (
<div style={{ marginTop: 6, marginLeft: 4 }}>
{commit.files.map((file) => (
<FileItem key={file.path} file={file} onOpenNote={onOpenNote} />
))}
</div>
)}
</div>
)
}
function DayGroup({ label, commits, onOpenNote }: {
label: string; commits: PulseCommit[]; onOpenNote?: (path: string) => void
}) {
const [collapsed, setCollapsed] = useState(false)
const Chevron = collapsed ? CaretRight : CaretDown
return (
<div>
<div
className="flex cursor-pointer select-none items-center border-b border-border bg-muted/50 transition-colors hover:bg-muted"
style={{ padding: '6px 16px', gap: 6 }}
onClick={() => setCollapsed((v) => !v)}
>
<Chevron size={12} className="text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-[11px] text-muted-foreground">
({commits.length} {commits.length === 1 ? 'commit' : 'commits'})
</span>
</div>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} onOpenNote={onOpenNote} />
))}
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<Pulse size={32} style={{ marginBottom: 8, opacity: 0.5 }} />
<p className="text-[13px]">No activity yet</p>
<p className="text-[12px]" style={{ marginTop: 4 }}>
Commit changes to see your vault's pulse
</p>
</div>
)
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
<button
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>
Retry
</button>
</div>
)
}
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const batchSize = 30
const loadPulse = useCallback(async (limit: number) => {
setLoading(true)
setError(null)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit })
setCommits(result)
setHasMore(result.length >= limit)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
setError(msg)
} finally {
setLoading(false)
}
}, [vaultPath])
useEffect(() => { loadPulse(batchSize) }, [loadPulse])
const handleLoadMore = useCallback(() => {
const nextLimit = commits.length + batchSize
loadPulse(nextLimit)
}, [commits.length, loadPulse])
const dayGroups = groupCommitsByDay(commits)
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
<div className="flex items-center" style={{ gap: 8 }}>
{sidebarCollapsed && onExpandSidebar && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onExpandSidebar}
aria-label="Expand sidebar"
>
<CaretRight size={14} weight="bold" />
</button>
)}
<Pulse size={16} className="text-primary" />
<span className="text-[14px] font-semibold text-foreground">Pulse</span>
</div>
</div>
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{loading && commits.length === 0 ? (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity…</span>
</div>
) : error ? (
<ErrorState message={error} onRetry={() => loadPulse(batchSize)} />
) : commits.length === 0 ? (
<EmptyState />
) : (
<>
{Array.from(dayGroups.entries()).map(([day, dayCommits]) => (
<DayGroup
key={day}
label={formatDayLabel(day)}
commits={dayCommits}
onOpenNote={onOpenNote}
/>
))}
{hasMore && (
<div style={{ padding: '12px 16px' }}>
<button
className="flex w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent py-2 text-[12px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Loading' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</div>
)
})

View File

@@ -15,7 +15,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -38,6 +38,7 @@ interface SidebarProps {
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
isGitVault?: boolean
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
@@ -270,7 +271,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
modifiedCount = 0, onCommitPush, onCollapse,
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -359,6 +360,7 @@ export const Sidebar = memo(function Sidebar({
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
)}
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
</div>
{/* Sections header + visibility popover */}

View File

@@ -26,7 +26,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
// --- NavItem ---
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled }: {
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip }: {
icon: ComponentType<IconProps>
label: string
count?: number
@@ -36,10 +36,11 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
badgeStyle?: React.CSSProperties
onClick?: () => void
disabled?: boolean
disabledTooltip?: string
}) {
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title="Coming soon">
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
</div>

View File

@@ -87,7 +87,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const { onSelect } = config
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes') => {
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse') => {
onSelect({ kind: 'filter', filter })
}, [onSelect])

View File

@@ -219,6 +219,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types'
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -173,6 +173,16 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)
const commits: PulseCommit[] = [
{ hash: 'a1b2c3d4e5f6', shortHash: 'a1b2c3d', message: 'Update project notes and add new experiment', date: ts - 3600, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6', files: [{ path: 'project/26q1-laputa-app.md', status: 'modified', title: '26q1 laputa app' }, { path: 'experiment/ai-search.md', status: 'added', title: 'ai search' }], added: 1, modified: 1, deleted: 0 },
{ hash: 'b2c3d4e5f6g7', shortHash: 'b2c3d4e', message: 'Reorganize people notes', date: ts - 86400, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/b2c3d4e5f6g7', files: [{ path: 'person/alice-johnson.md', status: 'modified', title: 'alice johnson' }, { path: 'person/bob-smith.md', status: 'modified', title: 'bob smith' }, { path: 'person/old-contact.md', status: 'deleted', title: 'old contact' }], added: 0, modified: 2, deleted: 1 },
{ hash: 'c3d4e5f6g7h8', shortHash: 'c3d4e5f', message: 'Add daily journal entry', date: ts - 172800, githubUrl: null, files: [{ path: 'note/2026-03-03.md', status: 'added', title: '2026 03 03' }], added: 1, modified: 0, deleted: 0 },
]
return commits.slice(0, limit)
},
get_conflict_files: (): string[] => [],
get_conflict_mode: () => 'none',
check_claude_cli: () => ({ installed: false, version: null }),

View File

@@ -151,7 +151,25 @@ export interface VaultConfig {
hidden_sections: string[] | null
}
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes'
export interface PulseFile {
path: string
status: 'added' | 'modified' | 'deleted'
title: string
}
export interface PulseCommit {
hash: string
shortHash: string
message: string
date: number
githubUrl: string | null
files: PulseFile[]
added: number
modified: number
deleted: number
}
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse'
export type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }

View File

@@ -334,6 +334,7 @@ function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[]
if (filter === 'all') return entries.filter(isActive)
if (filter === 'archived') return entries.filter((e) => e.archived && !e.trashed)
if (filter === 'trash') return entries.filter((e) => e.trashed)
if (filter === 'pulse') return []
return []
}

File diff suppressed because one or more lines are too long