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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -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} />
|
||||
</>
|
||||
|
||||
220
src/components/PulseView.test.tsx
Normal file
220
src/components/PulseView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
297
src/components/PulseView.tsx
Normal file
297
src/components/PulseView.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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?.() },
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@@ -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 }
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
|
||||
40317
ui-design.pen
40317
ui-design.pen
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user