generated from nhcarrigan/template
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<MemoryFileInfo>`; 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
This commit is contained in:
+129
-9
@@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
|
||||
.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<String> {
|
||||
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<String, String> {
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct MemoryFilesResponse {
|
||||
pub files: Vec<String>,
|
||||
pub files: Vec<MemoryFileInfo>,
|
||||
}
|
||||
|
||||
/// Extract the first `# Heading` from a string of file content.
|
||||
fn extract_first_heading(content: &str) -> Option<String> {
|
||||
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<MemoryFilesResponse, String> {
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files: Vec<String> = stdout
|
||||
let paths: Vec<String> = 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<MemoryFilesResponse, String> {
|
||||
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<String>) -> std::io::Result<()> {
|
||||
fn find_memory_files(
|
||||
dir: &std::path::Path,
|
||||
files: &mut Vec<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
<div class="panel-layout">
|
||||
<div class="file-list">
|
||||
{#each memoryFiles as file (file)}
|
||||
{#each memoryFiles as file (file.path)}
|
||||
<button
|
||||
class="file-item"
|
||||
class:active={selectedFile === file}
|
||||
onclick={() => loadFileContent(file)}
|
||||
class:active={selectedFile === file.path}
|
||||
onclick={() => loadFileContent(file.path)}
|
||||
title={getFileName(file.path)}
|
||||
>
|
||||
<svg
|
||||
class="file-icon"
|
||||
@@ -171,7 +181,7 @@
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="file-name">{getFileName(file)}</span>
|
||||
<span class="file-name">{getDisplayName(file)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -179,7 +189,12 @@
|
||||
<div class="file-viewer">
|
||||
{#if selectedFile && fileContent}
|
||||
<div class="viewer-header">
|
||||
<h4>{getFileName(selectedFile)}</h4>
|
||||
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
||||
<h4>{getDisplayName(activeFile)}</h4>
|
||||
{#if activeFile.heading}
|
||||
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<Markdown content={fileContent} />
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user