generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user