generated from nhcarrigan/template
feat: codebase mapper with CODEBASE.md generation (#190)
- Add scan_project Rust command that walks the directory tree and detects project type - Add CODEBASE as a managed ProjectFile type alongside PROJECT/REQUIREMENTS/ROADMAP/STATE - Add mapCodebase() store function that scans and sends an analysis prompt to Claude - Add CODEBASE tab to ProjectContextPanel with Map/Remap buttons and auto-reload on idle - Update PROJECT_CONTEXT_SYSTEM_ADDENDUM to include CODEBASE.md reference
This commit is contained in:
@@ -2337,6 +2337,160 @@ pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Codebase Mapper ====================
|
||||||
|
|
||||||
|
/// Directories to skip when scanning (always ignored regardless of .gitignore)
|
||||||
|
const SCAN_SKIP_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
".next",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"out",
|
||||||
|
"__pycache__",
|
||||||
|
".cache",
|
||||||
|
".pytest_cache",
|
||||||
|
"vendor",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"coverage",
|
||||||
|
".nyc_output",
|
||||||
|
"venv",
|
||||||
|
".venv",
|
||||||
|
"env",
|
||||||
|
".tox",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Files that indicate the project type
|
||||||
|
const PROJECT_MARKERS: &[(&str, &str)] = &[
|
||||||
|
("Cargo.toml", "Rust"),
|
||||||
|
("package.json", "Node.js"),
|
||||||
|
("pyproject.toml", "Python"),
|
||||||
|
("requirements.txt", "Python"),
|
||||||
|
("go.mod", "Go"),
|
||||||
|
("pom.xml", "Java (Maven)"),
|
||||||
|
("build.gradle", "Java (Gradle)"),
|
||||||
|
("Gemfile", "Ruby"),
|
||||||
|
("composer.json", "PHP"),
|
||||||
|
("*.csproj", "C#/.NET"),
|
||||||
|
("CMakeLists.txt", "C/C++ (CMake)"),
|
||||||
|
("Makefile", "C/C++"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectScan {
|
||||||
|
pub working_dir: String,
|
||||||
|
pub file_tree: String,
|
||||||
|
pub detected_type: String,
|
||||||
|
pub key_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels.
|
||||||
|
fn build_file_tree(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
prefix: &str,
|
||||||
|
depth: usize,
|
||||||
|
max_depth: usize,
|
||||||
|
lines: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
if depth > max_depth {
|
||||||
|
lines.push(format!("{}...", prefix));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items: Vec<std::fs::DirEntry> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.collect();
|
||||||
|
items.sort_by_key(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
|
// Sort: hidden last, directories first
|
||||||
|
let is_hidden = name.starts_with('.');
|
||||||
|
let is_dir = e.path().is_dir();
|
||||||
|
(is_hidden, !is_dir, name)
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = items.len();
|
||||||
|
for (i, entry) in items.iter().enumerate() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let is_last = i == count - 1;
|
||||||
|
let connector = if is_last { "└── " } else { "├── " };
|
||||||
|
let child_prefix = if is_last {
|
||||||
|
format!("{} ", prefix)
|
||||||
|
} else {
|
||||||
|
format!("{}│ ", prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if SCAN_SKIP_DIRS.contains(&name.as_str()) {
|
||||||
|
lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(format!("{}{}{}/", prefix, connector, name));
|
||||||
|
build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines);
|
||||||
|
} else {
|
||||||
|
lines.push(format!("{}{}{}", prefix, connector, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
|
||||||
|
let dir_path = std::path::Path::new(&working_dir);
|
||||||
|
|
||||||
|
if !dir_path.exists() {
|
||||||
|
return Err(format!("Directory does not exist: {}", working_dir));
|
||||||
|
}
|
||||||
|
if !dir_path.is_dir() {
|
||||||
|
return Err(format!("Path is not a directory: {}", working_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect project type by checking for marker files
|
||||||
|
let mut detected_type = "Unknown".to_string();
|
||||||
|
let mut key_files: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for (marker, project_type) in PROJECT_MARKERS {
|
||||||
|
let marker_path = dir_path.join(marker);
|
||||||
|
if marker_path.exists() {
|
||||||
|
if detected_type == "Unknown" {
|
||||||
|
detected_type = project_type.to_string();
|
||||||
|
}
|
||||||
|
key_files.push(marker.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also collect other notable root-level files
|
||||||
|
let notable_root_files = &[
|
||||||
|
"README.md", "CLAUDE.md", "LICENSE", ".env.example",
|
||||||
|
"docker-compose.yml", "Dockerfile", ".github",
|
||||||
|
"tsconfig.json", "vitest.config.ts", "eslint.config.js",
|
||||||
|
"check-all.sh", "tauri.conf.json",
|
||||||
|
];
|
||||||
|
for file in notable_root_files {
|
||||||
|
let file_path = dir_path.join(file);
|
||||||
|
if file_path.exists() && !key_files.contains(&file.to_string()) {
|
||||||
|
key_files.push(file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build file tree (max 4 levels deep)
|
||||||
|
let mut lines: Vec<String> = vec![format!("{}/", working_dir)];
|
||||||
|
build_file_tree(dir_path, "", 0, 4, &mut lines);
|
||||||
|
let file_tree = lines.join("\n");
|
||||||
|
|
||||||
|
Ok(ProjectScan {
|
||||||
|
working_dir,
|
||||||
|
file_tree,
|
||||||
|
detected_type,
|
||||||
|
key_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ pub fn run() {
|
|||||||
save_draft,
|
save_draft,
|
||||||
delete_draft,
|
delete_draft,
|
||||||
delete_all_drafts,
|
delete_all_drafts,
|
||||||
|
scan_project,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import {
|
import {
|
||||||
projectContextStore,
|
projectContextStore,
|
||||||
PROJECT_FILE_NAMES,
|
PROJECT_FILE_NAMES,
|
||||||
type ProjectFile,
|
type ProjectFile,
|
||||||
} from "$lib/stores/projectContext";
|
} from "$lib/stores/projectContext";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -14,15 +17,17 @@
|
|||||||
|
|
||||||
const { onClose, onInject, workingDirectory }: Props = $props();
|
const { onClose, onInject, workingDirectory }: Props = $props();
|
||||||
|
|
||||||
const PROJECT_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
const ALL_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE", "CODEBASE"];
|
||||||
|
|
||||||
const contents = $derived(projectContextStore.contents);
|
const contents = $derived(projectContextStore.contents);
|
||||||
const isLoading = $derived(projectContextStore.isLoading);
|
const isLoading = $derived(projectContextStore.isLoading);
|
||||||
const isSaving = $derived(projectContextStore.isSaving);
|
const isSaving = $derived(projectContextStore.isSaving);
|
||||||
const activeFile = $derived(projectContextStore.activeFile);
|
const activeFile = $derived(projectContextStore.activeFile);
|
||||||
|
const isMappingCodebase = $derived(projectContextStore.isMappingCodebase);
|
||||||
|
|
||||||
let editContent = $state("");
|
let editContent = $state("");
|
||||||
let hasUnsavedChanges = $state(false);
|
let hasUnsavedChanges = $state(false);
|
||||||
|
let previousCharacterState = $state<string>("idle");
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
projectContextStore.loadAll(workingDirectory);
|
projectContextStore.loadAll(workingDirectory);
|
||||||
@@ -31,10 +36,24 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const file = $activeFile;
|
const file = $activeFile;
|
||||||
const fileContent = $contents[file];
|
const fileContent = $contents[file];
|
||||||
editContent = fileContent ?? projectContextStore.getTemplate(file);
|
if (file === "CODEBASE") {
|
||||||
|
editContent = fileContent ?? "";
|
||||||
|
} else {
|
||||||
|
editContent = fileContent ?? projectContextStore.getTemplate(file);
|
||||||
|
}
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-reload CODEBASE.md when Claude finishes generating it
|
||||||
|
$effect(() => {
|
||||||
|
const currentState = $characterState;
|
||||||
|
if ($isMappingCodebase && previousCharacterState !== "idle" && currentState === "idle") {
|
||||||
|
projectContextStore.loadFile("CODEBASE", workingDirectory);
|
||||||
|
projectContextStore.finishMapping();
|
||||||
|
}
|
||||||
|
previousCharacterState = currentState;
|
||||||
|
});
|
||||||
|
|
||||||
function handleTabSwitch(file: ProjectFile): void {
|
function handleTabSwitch(file: ProjectFile): void {
|
||||||
projectContextStore.setActiveFile(file);
|
projectContextStore.setActiveFile(file);
|
||||||
}
|
}
|
||||||
@@ -63,6 +82,12 @@
|
|||||||
function fileExists(file: ProjectFile): boolean {
|
function fileExists(file: ProjectFile): boolean {
|
||||||
return $contents[file] !== null;
|
return $contents[file] !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMapCodebase(): Promise<void> {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
await projectContextStore.mapCodebase(workingDirectory, conversationId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +111,25 @@
|
|||||||
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
Project Context
|
Project Context
|
||||||
</h2>
|
</h2>
|
||||||
{#if $isLoading[$activeFile]}
|
{#if $activeFile === "CODEBASE"}
|
||||||
|
{#if $isMappingCodebase}
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">Mapping codebase...</span>
|
||||||
|
{:else if $isLoading[$activeFile]}
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||||
|
{:else if fileExists($activeFile)}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||||
|
>
|
||||||
|
✓ File exists
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||||
|
>
|
||||||
|
✗ Not generated
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else if $isLoading[$activeFile]}
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||||
{:else if fileExists($activeFile)}
|
{:else if fileExists($activeFile)}
|
||||||
<span
|
<span
|
||||||
@@ -123,7 +166,7 @@
|
|||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div class="flex border-b border-[var(--border-color)] px-4">
|
<div class="flex border-b border-[var(--border-color)] px-4">
|
||||||
{#each PROJECT_FILES as file (file)}
|
{#each ALL_FILES as file (file)}
|
||||||
<button
|
<button
|
||||||
onclick={() => handleTabSwitch(file)}
|
onclick={() => handleTabSwitch(file)}
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
|
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
|
||||||
@@ -140,13 +183,47 @@
|
|||||||
|
|
||||||
<!-- Editor area -->
|
<!-- Editor area -->
|
||||||
<div class="flex-1 overflow-hidden p-4 min-h-0">
|
<div class="flex-1 overflow-hidden p-4 min-h-0">
|
||||||
<textarea
|
{#if $activeFile === "CODEBASE" && !fileExists("CODEBASE") && !$isMappingCodebase}
|
||||||
value={editContent}
|
<!-- CODEBASE not generated yet — show prompt to map -->
|
||||||
oninput={handleTextChange}
|
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
|
||||||
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
<div class="text-4xl">🗺️</div>
|
||||||
placeholder="File content will appear here..."
|
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Codebase Map Yet</h3>
|
||||||
spellcheck="false"
|
<p class="text-sm text-[var(--text-secondary)] max-w-md">
|
||||||
></textarea>
|
Generate a <span class="font-mono text-xs">CODEBASE.md</span> file by asking Claude to analyse
|
||||||
|
this project. Claude will scan the directory structure and create a comprehensive overview
|
||||||
|
of the architecture and key components.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={handleMapCodebase}
|
||||||
|
class="px-5 py-2 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Map Codebase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if $activeFile === "CODEBASE" && $isMappingCodebase}
|
||||||
|
<!-- Mapping in progress -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
|
||||||
|
<div class="text-4xl animate-spin">⚙️</div>
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Mapping Codebase...</h3>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
|
Claude is analysing the project and writing <span class="font-mono text-xs"
|
||||||
|
>CODEBASE.md</span
|
||||||
|
>. This will auto-reload when complete.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
oninput={handleTextChange}
|
||||||
|
readonly={$activeFile === "CODEBASE"}
|
||||||
|
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed {$activeFile ===
|
||||||
|
'CODEBASE'
|
||||||
|
? 'opacity-80 cursor-default'
|
||||||
|
: ''}"
|
||||||
|
placeholder="File content will appear here..."
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -157,29 +234,50 @@
|
|||||||
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
|
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
{#if $activeFile === "CODEBASE"}
|
||||||
onclick={handleUseTemplate}
|
<button
|
||||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
onclick={() => projectContextStore.loadFile("CODEBASE", workingDirectory)}
|
||||||
>
|
disabled={$isLoading.CODEBASE || $isMappingCodebase}
|
||||||
Use Template
|
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
</button>
|
>
|
||||||
<button
|
Refresh
|
||||||
onclick={handleInject}
|
</button>
|
||||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
<button
|
||||||
>
|
onclick={handleMapCodebase}
|
||||||
Inject into Prompt
|
disabled={$isMappingCodebase}
|
||||||
</button>
|
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
<button
|
>
|
||||||
onclick={handleSave}
|
{#if $isMappingCodebase}
|
||||||
disabled={$isSaving[$activeFile]}
|
Mapping...
|
||||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
{:else}
|
||||||
>
|
{fileExists("CODEBASE") ? "Remap Codebase" : "Map Codebase"}
|
||||||
{#if $isSaving[$activeFile]}
|
{/if}
|
||||||
Saving...
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
Save
|
<button
|
||||||
{/if}
|
onclick={handleUseTemplate}
|
||||||
</button>
|
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Use Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleInject}
|
||||||
|
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Inject into Prompt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={$isSaving[$activeFile]}
|
||||||
|
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if $isSaving[$activeFile]}
|
||||||
|
Saving...
|
||||||
|
{:else}
|
||||||
|
Save
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,24 +9,30 @@ import {
|
|||||||
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||||
injectTextStore,
|
injectTextStore,
|
||||||
type ProjectFile,
|
type ProjectFile,
|
||||||
|
type ProjectScan,
|
||||||
} from "./projectContext";
|
} from "./projectContext";
|
||||||
|
|
||||||
describe("PROJECT_FILE_NAMES", () => {
|
describe("PROJECT_FILE_NAMES", () => {
|
||||||
it("maps all four project file types", () => {
|
it("maps all five project file types", () => {
|
||||||
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
|
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
|
||||||
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
|
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
|
||||||
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
|
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
|
||||||
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
|
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
|
||||||
|
expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PROJECT_TEMPLATES", () => {
|
describe("PROJECT_TEMPLATES", () => {
|
||||||
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||||
|
|
||||||
it.each(files)("returns a non-empty template for %s", (file) => {
|
it.each(editableFiles)("returns a non-empty template for %s", (file) => {
|
||||||
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
|
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
|
||||||
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
|
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("has an empty string template for CODEBASE (auto-generated)", () => {
|
||||||
|
expect(PROJECT_TEMPLATES.CODEBASE).toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("projectContextStore", () => {
|
describe("projectContextStore", () => {
|
||||||
@@ -41,6 +47,7 @@ describe("projectContextStore", () => {
|
|||||||
expect(state.REQUIREMENTS).toBeNull();
|
expect(state.REQUIREMENTS).toBeNull();
|
||||||
expect(state.ROADMAP).toBeNull();
|
expect(state.ROADMAP).toBeNull();
|
||||||
expect(state.STATE).toBeNull();
|
expect(state.STATE).toBeNull();
|
||||||
|
expect(state.CODEBASE).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has false isLoading for all files", () => {
|
it("has false isLoading for all files", () => {
|
||||||
@@ -49,6 +56,7 @@ describe("projectContextStore", () => {
|
|||||||
expect(state.REQUIREMENTS).toBe(false);
|
expect(state.REQUIREMENTS).toBe(false);
|
||||||
expect(state.ROADMAP).toBe(false);
|
expect(state.ROADMAP).toBe(false);
|
||||||
expect(state.STATE).toBe(false);
|
expect(state.STATE).toBe(false);
|
||||||
|
expect(state.CODEBASE).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has false isSaving for all files", () => {
|
it("has false isSaving for all files", () => {
|
||||||
@@ -57,18 +65,25 @@ describe("projectContextStore", () => {
|
|||||||
expect(state.REQUIREMENTS).toBe(false);
|
expect(state.REQUIREMENTS).toBe(false);
|
||||||
expect(state.ROADMAP).toBe(false);
|
expect(state.ROADMAP).toBe(false);
|
||||||
expect(state.STATE).toBe(false);
|
expect(state.STATE).toBe(false);
|
||||||
|
expect(state.CODEBASE).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has PROJECT as the default activeFile", () => {
|
it("has PROJECT as the default activeFile", () => {
|
||||||
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("has false isMappingCodebase initially", () => {
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes all expected methods", () => {
|
it("exposes all expected methods", () => {
|
||||||
expect(typeof projectContextStore.loadFile).toBe("function");
|
expect(typeof projectContextStore.loadFile).toBe("function");
|
||||||
expect(typeof projectContextStore.saveFile).toBe("function");
|
expect(typeof projectContextStore.saveFile).toBe("function");
|
||||||
expect(typeof projectContextStore.loadAll).toBe("function");
|
expect(typeof projectContextStore.loadAll).toBe("function");
|
||||||
expect(typeof projectContextStore.setActiveFile).toBe("function");
|
expect(typeof projectContextStore.setActiveFile).toBe("function");
|
||||||
expect(typeof projectContextStore.getTemplate).toBe("function");
|
expect(typeof projectContextStore.getTemplate).toBe("function");
|
||||||
|
expect(typeof projectContextStore.mapCodebase).toBe("function");
|
||||||
|
expect(typeof projectContextStore.finishMapping).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +218,7 @@ describe("projectContextStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("loadAll", () => {
|
describe("loadAll", () => {
|
||||||
it("loads all four files in parallel", async () => {
|
it("loads all five files in parallel", async () => {
|
||||||
setMockInvokeResult("read_file_content", "file content");
|
setMockInvokeResult("read_file_content", "file content");
|
||||||
|
|
||||||
await projectContextStore.loadAll("/home/naomi/myproject");
|
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||||
@@ -215,6 +230,7 @@ describe("projectContextStore", () => {
|
|||||||
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
|
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
|
||||||
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
|
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
|
||||||
expect(paths).toContain("/home/naomi/myproject/STATE.md");
|
expect(paths).toContain("/home/naomi/myproject/STATE.md");
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/CODEBASE.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets all files isLoading to false after completion", async () => {
|
it("sets all files isLoading to false after completion", async () => {
|
||||||
@@ -227,6 +243,7 @@ describe("projectContextStore", () => {
|
|||||||
expect(loadingState.REQUIREMENTS).toBe(false);
|
expect(loadingState.REQUIREMENTS).toBe(false);
|
||||||
expect(loadingState.ROADMAP).toBe(false);
|
expect(loadingState.ROADMAP).toBe(false);
|
||||||
expect(loadingState.STATE).toBe(false);
|
expect(loadingState.STATE).toBe(false);
|
||||||
|
expect(loadingState.CODEBASE).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +274,91 @@ describe("projectContextStore", () => {
|
|||||||
const uniqueTemplates = new Set(templates);
|
const uniqueTemplates = new Set(templates);
|
||||||
expect(uniqueTemplates.size).toBe(files.length);
|
expect(uniqueTemplates.size).toBe(files.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns empty string for CODEBASE", () => {
|
||||||
|
expect(projectContextStore.getTemplate("CODEBASE")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCodebase", () => {
|
||||||
|
const mockScan: ProjectScan = {
|
||||||
|
working_dir: "/home/naomi/myproject",
|
||||||
|
file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json",
|
||||||
|
detected_type: "Node.js",
|
||||||
|
key_files: ["package.json"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("calls scan_project with the working directory", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("scan_project", {
|
||||||
|
workingDir: "/home/naomi/myproject",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls send_prompt with the conversation id and a non-empty prompt", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.stringContaining("CODEBASE.md"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes detected project type", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Node.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes file tree", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("package.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets isMappingCodebase to false on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when scan_project fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("finishMapping", () => {
|
||||||
|
it("sets isMappingCodebase to false", () => {
|
||||||
|
projectContextStore.finishMapping();
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,11 +368,12 @@ describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => {
|
|||||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mentions all four context file names", () => {
|
it("mentions all five context file names", () => {
|
||||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
|
||||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
|
||||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
|
||||||
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE";
|
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE" | "CODEBASE";
|
||||||
|
|
||||||
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
|
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
|
||||||
PROJECT: "PROJECT.md",
|
PROJECT: "PROJECT.md",
|
||||||
REQUIREMENTS: "REQUIREMENTS.md",
|
REQUIREMENTS: "REQUIREMENTS.md",
|
||||||
ROADMAP: "ROADMAP.md",
|
ROADMAP: "ROADMAP.md",
|
||||||
STATE: "STATE.md",
|
STATE: "STATE.md",
|
||||||
|
CODEBASE: "CODEBASE.md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
||||||
@@ -51,16 +52,25 @@ export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
`,
|
`,
|
||||||
|
CODEBASE: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
|
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
|
||||||
|
|
||||||
|
export interface ProjectScan {
|
||||||
|
working_dir: string;
|
||||||
|
file_tree: string;
|
||||||
|
detected_type: string;
|
||||||
|
key_files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
function createProjectContextStore() {
|
function createProjectContextStore() {
|
||||||
const contents = writable<Record<ProjectFile, string | null>>({
|
const contents = writable<Record<ProjectFile, string | null>>({
|
||||||
PROJECT: null,
|
PROJECT: null,
|
||||||
REQUIREMENTS: null,
|
REQUIREMENTS: null,
|
||||||
ROADMAP: null,
|
ROADMAP: null,
|
||||||
STATE: null,
|
STATE: null,
|
||||||
|
CODEBASE: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = writable<Record<ProjectFile, boolean>>({
|
const isLoading = writable<Record<ProjectFile, boolean>>({
|
||||||
@@ -68,6 +78,7 @@ function createProjectContextStore() {
|
|||||||
REQUIREMENTS: false,
|
REQUIREMENTS: false,
|
||||||
ROADMAP: false,
|
ROADMAP: false,
|
||||||
STATE: false,
|
STATE: false,
|
||||||
|
CODEBASE: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSaving = writable<Record<ProjectFile, boolean>>({
|
const isSaving = writable<Record<ProjectFile, boolean>>({
|
||||||
@@ -75,9 +86,11 @@ function createProjectContextStore() {
|
|||||||
REQUIREMENTS: false,
|
REQUIREMENTS: false,
|
||||||
ROADMAP: false,
|
ROADMAP: false,
|
||||||
STATE: false,
|
STATE: false,
|
||||||
|
CODEBASE: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeFile = writable<ProjectFile>("PROJECT");
|
const activeFile = writable<ProjectFile>("PROJECT");
|
||||||
|
const isMappingCodebase = writable<boolean>(false);
|
||||||
|
|
||||||
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
|
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
|
||||||
isLoading.update((state) => ({ ...state, [file]: true }));
|
isLoading.update((state) => ({ ...state, [file]: true }));
|
||||||
@@ -123,19 +136,67 @@ function createProjectContextStore() {
|
|||||||
return PROJECT_TEMPLATES[file];
|
return PROJECT_TEMPLATES[file];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mapCodebase(workingDirectory: string, conversationId: string): Promise<void> {
|
||||||
|
isMappingCodebase.set(true);
|
||||||
|
try {
|
||||||
|
const scan = await invoke<ProjectScan>("scan_project", {
|
||||||
|
workingDir: workingDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = buildCodebaseMapPrompt(scan);
|
||||||
|
await invoke("send_prompt", { conversationId, message: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to map codebase:", error);
|
||||||
|
isMappingCodebase.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMapping(): void {
|
||||||
|
isMappingCodebase.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contents: { subscribe: contents.subscribe },
|
contents: { subscribe: contents.subscribe },
|
||||||
isLoading: { subscribe: isLoading.subscribe },
|
isLoading: { subscribe: isLoading.subscribe },
|
||||||
isSaving: { subscribe: isSaving.subscribe },
|
isSaving: { subscribe: isSaving.subscribe },
|
||||||
activeFile: { subscribe: activeFile.subscribe },
|
activeFile: { subscribe: activeFile.subscribe },
|
||||||
|
isMappingCodebase: { subscribe: isMappingCodebase.subscribe },
|
||||||
loadFile,
|
loadFile,
|
||||||
saveFile,
|
saveFile,
|
||||||
loadAll,
|
loadAll,
|
||||||
setActiveFile,
|
setActiveFile,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
|
mapCodebase,
|
||||||
|
finishMapping,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCodebaseMapPrompt(scan: ProjectScan): string {
|
||||||
|
const keyFilesSection =
|
||||||
|
scan.key_files.length > 0
|
||||||
|
? `\n\nKey files detected:\n${scan.key_files.map((f) => `- ${f}`).join("\n")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `Please analyse this codebase and generate a comprehensive \`CODEBASE.md\` file in the working directory (${scan.working_dir}).
|
||||||
|
|
||||||
|
Project type detected: **${scan.detected_type}**${keyFilesSection}
|
||||||
|
|
||||||
|
Directory structure:
|
||||||
|
\`\`\`
|
||||||
|
${scan.file_tree}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The CODEBASE.md file should include:
|
||||||
|
1. **Overview** — what the project does and its purpose
|
||||||
|
2. **Architecture** — key directories, how the code is organised, and the overall structure
|
||||||
|
3. **Key Components** — the most important files and modules, what they do, and how they interact
|
||||||
|
4. **Data Flow** — how data moves through the system (if applicable)
|
||||||
|
5. **Dependencies** — notable external dependencies and why they are used
|
||||||
|
6. **Development Notes** — anything helpful for a developer new to the codebase
|
||||||
|
|
||||||
|
Write the file concisely but thoroughly. Focus on information that helps a developer understand the codebase quickly. Use the actual file structure above to inform your analysis — read the key files as needed before writing.`;
|
||||||
|
}
|
||||||
|
|
||||||
export const projectContextStore = createProjectContextStore();
|
export const projectContextStore = createProjectContextStore();
|
||||||
|
|
||||||
// Signal store for injecting context into the active InputBar.
|
// Signal store for injecting context into the active InputBar.
|
||||||
@@ -152,4 +213,5 @@ The following project context files may exist in your working directory. If they
|
|||||||
- PROJECT.md — project overview, goals, and architecture
|
- PROJECT.md — project overview, goals, and architecture
|
||||||
- REQUIREMENTS.md — functional and non-functional requirements
|
- REQUIREMENTS.md — functional and non-functional requirements
|
||||||
- ROADMAP.md — current sprint, backlog, and completed work
|
- ROADMAP.md — current sprint, backlog, and completed work
|
||||||
- STATE.md — current state, known issues, and next steps`;
|
- STATE.md — current state, known issues, and next steps
|
||||||
|
- CODEBASE.md — auto-generated codebase map and architecture overview`;
|
||||||
|
|||||||
Reference in New Issue
Block a user