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:
2026-03-06 13:39:29 -08:00
committed by Naomi Carrigan
parent 78dc838f36
commit 9136f3351d
5 changed files with 459 additions and 41 deletions
+154
View File
@@ -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)]
mod tests {
use super::*;
+1
View File
@@ -220,6 +220,7 @@ pub fn run() {
save_draft,
delete_draft,
delete_all_drafts,
scan_project,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+132 -34
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import {
projectContextStore,
PROJECT_FILE_NAMES,
type ProjectFile,
} from "$lib/stores/projectContext";
import { characterState } from "$lib/stores/character";
import { claudeStore } from "$lib/stores/claude";
interface Props {
onClose: () => void;
@@ -14,15 +17,17 @@
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 isLoading = $derived(projectContextStore.isLoading);
const isSaving = $derived(projectContextStore.isSaving);
const activeFile = $derived(projectContextStore.activeFile);
const isMappingCodebase = $derived(projectContextStore.isMappingCodebase);
let editContent = $state("");
let hasUnsavedChanges = $state(false);
let previousCharacterState = $state<string>("idle");
onMount(() => {
projectContextStore.loadAll(workingDirectory);
@@ -31,10 +36,24 @@
$effect(() => {
const file = $activeFile;
const fileContent = $contents[file];
editContent = fileContent ?? projectContextStore.getTemplate(file);
if (file === "CODEBASE") {
editContent = fileContent ?? "";
} else {
editContent = fileContent ?? projectContextStore.getTemplate(file);
}
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 {
projectContextStore.setActiveFile(file);
}
@@ -63,6 +82,12 @@
function fileExists(file: ProjectFile): boolean {
return $contents[file] !== null;
}
async function handleMapCodebase(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await projectContextStore.mapCodebase(workingDirectory, conversationId);
}
</script>
<div
@@ -86,7 +111,25 @@
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
Project Context
</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>
{:else if fileExists($activeFile)}
<span
@@ -123,7 +166,7 @@
<!-- Tab bar -->
<div class="flex border-b border-[var(--border-color)] px-4">
{#each PROJECT_FILES as file (file)}
{#each ALL_FILES as file (file)}
<button
onclick={() => handleTabSwitch(file)}
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
@@ -140,13 +183,47 @@
<!-- Editor area -->
<div class="flex-1 overflow-hidden p-4 min-h-0">
<textarea
value={editContent}
oninput={handleTextChange}
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"
placeholder="File content will appear here..."
spellcheck="false"
></textarea>
{#if $activeFile === "CODEBASE" && !fileExists("CODEBASE") && !$isMappingCodebase}
<!-- CODEBASE not generated yet — show prompt to map -->
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl">🗺️</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Codebase Map Yet</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md">
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>
<!-- Footer -->
@@ -157,29 +234,50 @@
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={handleUseTemplate}
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 $activeFile === "CODEBASE"}
<button
onclick={() => projectContextStore.loadFile("CODEBASE", workingDirectory)}
disabled={$isLoading.CODEBASE || $isMappingCodebase}
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"
>
Refresh
</button>
<button
onclick={handleMapCodebase}
disabled={$isMappingCodebase}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if $isMappingCodebase}
Mapping...
{:else}
{fileExists("CODEBASE") ? "Remap Codebase" : "Map Codebase"}
{/if}
</button>
{:else}
<button
onclick={handleUseTemplate}
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>
+108 -5
View File
@@ -9,24 +9,30 @@ import {
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
injectTextStore,
type ProjectFile,
type ProjectScan,
} from "./projectContext";
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.REQUIREMENTS).toBe("REQUIREMENTS.md");
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md");
});
});
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].length).toBeGreaterThan(0);
});
it("has an empty string template for CODEBASE (auto-generated)", () => {
expect(PROJECT_TEMPLATES.CODEBASE).toBe("");
});
});
describe("projectContextStore", () => {
@@ -41,6 +47,7 @@ describe("projectContextStore", () => {
expect(state.REQUIREMENTS).toBeNull();
expect(state.ROADMAP).toBeNull();
expect(state.STATE).toBeNull();
expect(state.CODEBASE).toBeNull();
});
it("has false isLoading for all files", () => {
@@ -49,6 +56,7 @@ describe("projectContextStore", () => {
expect(state.REQUIREMENTS).toBe(false);
expect(state.ROADMAP).toBe(false);
expect(state.STATE).toBe(false);
expect(state.CODEBASE).toBe(false);
});
it("has false isSaving for all files", () => {
@@ -57,18 +65,25 @@ describe("projectContextStore", () => {
expect(state.REQUIREMENTS).toBe(false);
expect(state.ROADMAP).toBe(false);
expect(state.STATE).toBe(false);
expect(state.CODEBASE).toBe(false);
});
it("has PROJECT as the default activeFile", () => {
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
});
it("has false isMappingCodebase initially", () => {
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
});
it("exposes all expected methods", () => {
expect(typeof projectContextStore.loadFile).toBe("function");
expect(typeof projectContextStore.saveFile).toBe("function");
expect(typeof projectContextStore.loadAll).toBe("function");
expect(typeof projectContextStore.setActiveFile).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", () => {
it("loads all four files in parallel", async () => {
it("loads all five files in parallel", async () => {
setMockInvokeResult("read_file_content", "file content");
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/ROADMAP.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 () => {
@@ -227,6 +243,7 @@ describe("projectContextStore", () => {
expect(loadingState.REQUIREMENTS).toBe(false);
expect(loadingState.ROADMAP).toBe(false);
expect(loadingState.STATE).toBe(false);
expect(loadingState.CODEBASE).toBe(false);
});
});
@@ -257,6 +274,91 @@ describe("projectContextStore", () => {
const uniqueTemplates = new Set(templates);
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);
});
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("REQUIREMENTS.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md");
});
});
+64 -2
View File
@@ -1,13 +1,14 @@
import { writable } from "svelte/store";
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> = {
PROJECT: "PROJECT.md",
REQUIREMENTS: "REQUIREMENTS.md",
ROADMAP: "ROADMAP.md",
STATE: "STATE.md",
CODEBASE: "CODEBASE.md",
};
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
@@ -51,16 +52,25 @@ export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
## Next Steps
`,
CODEBASE: "",
};
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() {
const contents = writable<Record<ProjectFile, string | null>>({
PROJECT: null,
REQUIREMENTS: null,
ROADMAP: null,
STATE: null,
CODEBASE: null,
});
const isLoading = writable<Record<ProjectFile, boolean>>({
@@ -68,6 +78,7 @@ function createProjectContextStore() {
REQUIREMENTS: false,
ROADMAP: false,
STATE: false,
CODEBASE: false,
});
const isSaving = writable<Record<ProjectFile, boolean>>({
@@ -75,9 +86,11 @@ function createProjectContextStore() {
REQUIREMENTS: false,
ROADMAP: false,
STATE: false,
CODEBASE: false,
});
const activeFile = writable<ProjectFile>("PROJECT");
const isMappingCodebase = writable<boolean>(false);
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
isLoading.update((state) => ({ ...state, [file]: true }));
@@ -123,19 +136,67 @@ function createProjectContextStore() {
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 {
contents: { subscribe: contents.subscribe },
isLoading: { subscribe: isLoading.subscribe },
isSaving: { subscribe: isSaving.subscribe },
activeFile: { subscribe: activeFile.subscribe },
isMappingCodebase: { subscribe: isMappingCodebase.subscribe },
loadFile,
saveFile,
loadAll,
setActiveFile,
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();
// 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
- REQUIREMENTS.md — functional and non-functional requirements
- 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`;