From c5feb9b43ccc734ee68a7d4c5ef4575716b014b5 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 18:19:53 -0800 Subject: [PATCH] feat: display friendly names for memory files (closes #177) The memory file list now shows human-readable titles instead of raw filenames wherever possible. - Rust: adds `MemoryFileInfo { path, heading }` and updates `MemoryFilesResponse` to use `Vec`; adds `extract_first_heading()` helper that scans the first `# Heading` from a file's content; both `list_memory_files_native` and `list_memory_files_via_wsl` now read each file and populate the heading field; raw filename remains available as a fallback - Frontend: updates `MemoryBrowserPanel.svelte` to use the richer type; adds `getDisplayName()` which returns the heading or falls back to the raw filename; file buttons use the display name with the raw filename as a tooltip; the viewer header also shows the display name with the filename shown as a subtitle when a heading is present - Tests: 8 new Rust unit tests for `extract_first_heading` (happy path, edge cases, empty content, whitespace); 12 new TypeScript tests for `getFileName` and `getDisplayName` mirrored from the component --- src-tauri/src/commands.rs | 138 ++++++++++++++++-- src/lib/components/MemoryBrowserPanel.svelte | 42 ++++-- src/lib/components/MemoryBrowserPanel.test.ts | 98 +++++++++++++ 3 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 src/lib/components/MemoryBrowserPanel.test.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index efc58c3..acddc96 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result { .map_err(|e| format!("Failed to read file: {}", e)) } +/// Read the first `# Heading` from a WSL file path (for Windows). +/// Returns `None` if the file cannot be read or has no top-level heading. +#[cfg(target_os = "windows")] +fn read_wsl_file_first_heading(path: &str) -> Option { + use std::process::Command; + + let output = Command::new("wsl") + .hide_window() + .args(["-e", "bash", "-c", &format!("head -20 '{}'", path)]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let content = String::from_utf8_lossy(&output.stdout); + extract_first_heading(&content) +} + /// Read file content via WSL (for Windows with WSL paths) #[allow(dead_code)] async fn read_file_via_wsl(path: &str) -> Result { @@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> { Ok(()) } +#[derive(serde::Serialize)] +pub struct MemoryFileInfo { + pub path: String, + pub heading: Option, +} + #[derive(serde::Serialize)] pub struct MemoryFilesResponse { - pub files: Vec, + pub files: Vec, +} + +/// Extract the first `# Heading` from a string of file content. +fn extract_first_heading(content: &str) -> Option { + content.lines().find_map(|line| { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("# ") { + let heading = rest.trim().to_string(); + if !heading.is_empty() { + return Some(heading); + } + } + None + }) } #[tauri::command] @@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result { } let stdout = String::from_utf8_lossy(&output.stdout); - let files: Vec = stdout + let paths: Vec = stdout .lines() .filter(|line| !line.trim().is_empty()) .map(|line| line.trim().to_string()) .collect(); + // Read first heading from each file via WSL + let mut files = Vec::new(); + for path in paths { + let heading = read_wsl_file_first_heading(&path); + files.push(MemoryFileInfo { path, heading }); + } + Ok(MemoryFilesResponse { files }) } @@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result { return Ok(MemoryFilesResponse { files: Vec::new() }); } - let mut memory_files = Vec::new(); + let mut memory_paths = Vec::new(); // Recursively find all memory directories - fn find_memory_files(dir: &std::path::Path, files: &mut Vec) -> std::io::Result<()> { + fn find_memory_files( + dir: &std::path::Path, + files: &mut Vec, + ) -> std::io::Result<()> { if !dir.is_dir() { return Ok(()); } @@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result { Ok(()) } - if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) { + if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) { return Err(format!("Failed to list memory files: {}", e)); } // Sort files alphabetically - memory_files.sort(); + memory_paths.sort(); - Ok(MemoryFilesResponse { - files: memory_files, - }) + // Read first heading from each file + let files = memory_paths + .into_iter() + .map(|path| { + let heading = fs::read_to_string(&path) + .ok() + .and_then(|content| extract_first_heading(&content)); + MemoryFileInfo { path, heading } + }) + .collect(); + + Ok(MemoryFilesResponse { files }) } #[tauri::command] @@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#; assert_eq!(servers[0].name, "asana"); assert_eq!(servers[1].name, "gitea"); } + + // ==================== extract_first_heading tests ==================== + + #[test] + fn test_extract_first_heading_returns_heading() { + let content = "# My Memory File\n\nSome content here."; + assert_eq!( + extract_first_heading(content), + Some("My Memory File".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_ignores_non_h1() { + let content = "## Section Header\n### Sub-section\nSome content."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_finds_first_h1_after_other_lines() { + let content = "Some intro text\n\n# The Real Title\n\nMore content."; + assert_eq!( + extract_first_heading(content), + Some("The Real Title".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_trims_whitespace() { + let content = "# Trimmed Heading \n\nContent."; + assert_eq!( + extract_first_heading(content), + Some("Trimmed Heading".to_string()) + ); + } + + #[test] + fn test_extract_first_heading_returns_none_for_empty_content() { + assert_eq!(extract_first_heading(""), None); + } + + #[test] + fn test_extract_first_heading_returns_none_for_empty_heading() { + let content = "# \n\nContent after empty heading."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_returns_none_when_no_headings() { + let content = "Just some plain text.\nNo headings here at all."; + assert_eq!(extract_first_heading(content), None); + } + + #[test] + fn test_extract_first_heading_handles_leading_whitespace_on_line() { + let content = " # Indented Heading\n\nContent."; + assert_eq!( + extract_first_heading(content), + Some("Indented Heading".to_string()) + ); + } } diff --git a/src/lib/components/MemoryBrowserPanel.svelte b/src/lib/components/MemoryBrowserPanel.svelte index aa62a37..e41c461 100644 --- a/src/lib/components/MemoryBrowserPanel.svelte +++ b/src/lib/components/MemoryBrowserPanel.svelte @@ -3,17 +3,22 @@ import { invoke } from "@tauri-apps/api/core"; import Markdown from "./Markdown.svelte"; - let memoryFiles: string[] = $state([]); + interface MemoryFileInfo { + path: string; + heading: string | null; + } + + interface MemoryFilesResponse { + files: MemoryFileInfo[]; + } + + let memoryFiles: MemoryFileInfo[] = $state([]); let selectedFile: string | null = $state(null); let fileContent: string = $state(""); let isLoading = $state(false); let error: string | null = $state(null); let isPanelOpen = $state(false); - interface MemoryFilesResponse { - files: string[]; - } - async function loadMemoryFiles() { isLoading = true; error = null; @@ -49,6 +54,10 @@ return path.split("/").pop() || path; } + function getDisplayName(file: MemoryFileInfo): string { + return file.heading ?? getFileName(file.path); + } + function togglePanel() { isPanelOpen = !isPanelOpen; if (isPanelOpen && memoryFiles.length === 0) { @@ -151,11 +160,12 @@ {:else}
- {#each memoryFiles as file (file)} + {#each memoryFiles as file (file.path)} {/each}
@@ -179,7 +189,12 @@
{#if selectedFile && fileContent}
-

{getFileName(selectedFile)}

+ {#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)} +

{getDisplayName(activeFile)}

+ {#if activeFile.heading} +

{getFileName(activeFile.path)}

+ {/if} + {/each}
@@ -438,6 +453,13 @@ color: var(--text-primary); } + .viewer-filename { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--text-tertiary); + font-family: monospace; + } + .viewer-content { flex: 1; padding: 1.5rem; diff --git a/src/lib/components/MemoryBrowserPanel.test.ts b/src/lib/components/MemoryBrowserPanel.test.ts new file mode 100644 index 0000000..d477f7f --- /dev/null +++ b/src/lib/components/MemoryBrowserPanel.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; + +// Mirror pure logic functions from MemoryBrowserPanel.svelte + +interface MemoryFileInfo { + path: string; + heading: string | null; +} + +function getFileName(path: string): string { + return path.split("/").pop() || path; +} + +function getDisplayName(file: MemoryFileInfo): string { + return file.heading ?? getFileName(file.path); +} + +describe("getFileName", () => { + it("extracts the filename from an absolute Unix path", () => { + expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md"); + }); + + it("extracts the filename from a nested path", () => { + expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe( + "debugging.md" + ); + }); + + it("returns the path itself when there is no slash", () => { + expect(getFileName("MEMORY.md")).toBe("MEMORY.md"); + }); + + it("returns the path when the path ends with a slash (empty filename)", () => { + // split('/').pop() returns '' for trailing slash → falls back to full path + expect(getFileName("/some/dir/")).toBe("/some/dir/"); + }); + + it("handles single-component paths", () => { + expect(getFileName("notes.md")).toBe("notes.md"); + }); + + it("handles Windows-style paths passed as Unix strings", () => { + // If the path contains no forward slashes, the whole string is the filename + expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe( + "C:\\Users\\naomi\\memory\\file.md" + ); + }); +}); + +describe("getDisplayName", () => { + it("returns the heading when the file has one", () => { + const file: MemoryFileInfo = { + path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md", + heading: "Hikari Desktop - Memory", + }; + expect(getDisplayName(file)).toBe("Hikari Desktop - Memory"); + }); + + it("falls back to the filename when heading is null", () => { + const file: MemoryFileInfo = { + path: "/home/naomi/.claude/projects/foo/memory/debugging.md", + heading: null, + }; + expect(getDisplayName(file)).toBe("debugging.md"); + }); + + it("falls back to the filename when heading is an empty string stored as null", () => { + const file: MemoryFileInfo = { + path: "/home/naomi/.claude/projects/foo/memory/patterns.md", + heading: null, + }; + expect(getDisplayName(file)).toBe("patterns.md"); + }); + + it("returns the heading even when it matches the filename", () => { + const file: MemoryFileInfo = { + path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md", + heading: "MEMORY", + }; + expect(getDisplayName(file)).toBe("MEMORY"); + }); + + it("returns a multi-word heading verbatim", () => { + const file: MemoryFileInfo = { + path: "/some/path/foo.md", + heading: "My Detailed Debugging Notes", + }; + expect(getDisplayName(file)).toBe("My Detailed Debugging Notes"); + }); + + it("falls back gracefully when path has no directory separators", () => { + const file: MemoryFileInfo = { + path: "lonely-file.md", + heading: null, + }; + expect(getDisplayName(file)).toBe("lonely-file.md"); + }); +});