feat: Improve HeroPrompt file selection and workspace management

- Refactor Directory struct and its methods.
- Update file selection logic for directories and files.
- Enhance prompt generation with better file mapping.
- Add unit tests for directory and file operations.
- Improve workspace management with auto-save and logging.
This commit is contained in:
Mahmoud-Emad
2025-10-12 12:16:52 +03:00
parent 40ad68e0ff
commit 923f8c24e7
24 changed files with 3528 additions and 1321 deletions

View File

@@ -0,0 +1,378 @@
# HeroPrompt Module
The `heroprompt` module provides a hierarchical workspace-based system for organizing code files and generating structured AI prompts. It enables developers to select files from multiple directories and generate formatted prompts for AI code analysis.
## Key Features
- **Hierarchical Organization**: HeroPrompt → Workspace → Directory → Files
- **Redis Persistence**: All data persists across sessions using Redis
- **Factory Pattern**: Clean API with `get()`, `delete()`, `exists()`, `list()` functions
- **File Selection**: Select specific files or entire directories for analysis
- **Active Workspace**: Manage multiple workspaces with one active at a time
- **Prompt Generation**: Generate structured prompts with file maps, contents, and instructions
- **Template-Based**: Uses V templates for consistent prompt formatting
## Basic Usage
### 1. Getting Started
```v
import freeflowuniverse.herolib.develop.heroprompt
// Create or get a HeroPrompt instance
mut hp := heroprompt.get(name: 'my_project', create: true)!
// Create a workspace (first workspace is automatically active)
mut workspace := hp.new_workspace(
name: 'my_workspace'
description: 'My project workspace'
)!
```
### 2. Adding Directories
```v
// Add directory and automatically scan all files
mut dir := workspace.add_directory(
path: '/path/to/your/code'
name: 'backend'
scan: true // Scans all files and subdirectories
)!
// Add another directory
mut frontend_dir := workspace.add_directory(
path: '/path/to/frontend'
name: 'frontend'
scan: true
)!
```
### 3. Selecting Files
```v
// Select specific files
dir.select_file(path: '/path/to/your/code/main.v')!
dir.select_file(path: '/path/to/your/code/utils.v')!
// Or select all files in a directory
frontend_dir.select_all()!
// Deselect files
dir.deselect_file(path: '/path/to/your/code/test.v')!
// Deselect all files
dir.deselect_all()!
```
### 4. Generating AI Prompts
```v
// Generate prompt with selected files
prompt := workspace.generate_prompt(
instruction: 'Review these files and suggest improvements'
)!
println(prompt)
// Or generate with specific files (overrides selection)
prompt2 := workspace.generate_prompt(
instruction: 'Analyze these specific files'
selected_files: ['/path/to/file1.v', '/path/to/file2.v']
)!
```
## Factory Functions
### `heroprompt.get(name: string, create: bool) !HeroPrompt`
Gets or creates a HeroPrompt instance.
```v
// Get existing instance or create new one
mut hp := heroprompt.get(name: 'my_project', create: true)!
// Get existing instance only (error if doesn't exist)
mut hp2 := heroprompt.get(name: 'my_project')!
```
### `heroprompt.delete(name: string) !`
Deletes a HeroPrompt instance from Redis.
```v
heroprompt.delete(name: 'my_project')!
```
### `heroprompt.exists(name: string) !bool`
Checks if a HeroPrompt instance exists.
```v
if heroprompt.exists(name: 'my_project')! {
println('Instance exists')
}
```
### `heroprompt.list() ![]string`
Lists all HeroPrompt instance names.
```v
instances := heroprompt.list()!
for name in instances {
println('Instance: ${name}')
}
```
## HeroPrompt Methods
### Workspace Management
#### `hp.new_workspace(name: string, description: string, is_active: bool) !&Workspace`
Creates a new workspace. The first workspace is automatically set as active.
```v
mut ws := hp.new_workspace(
name: 'backend'
description: 'Backend API workspace'
)!
```
#### `hp.get_workspace(name: string) !&Workspace`
Retrieves an existing workspace by name.
```v
mut ws := hp.get_workspace('backend')!
```
#### `hp.get_active_workspace() !&Workspace`
Returns the currently active workspace.
```v
mut active := hp.get_active_workspace()!
println('Active workspace: ${active.name}')
```
#### `hp.set_active_workspace(name: string) !`
Sets a workspace as active (deactivates all others).
```v
hp.set_active_workspace('frontend')!
```
#### `hp.list_workspaces() []&Workspace`
Lists all workspaces in the instance.
```v
workspaces := hp.list_workspaces()
for ws in workspaces {
println('Workspace: ${ws.name}')
}
```
#### `hp.delete_workspace(name: string) !`
Deletes a workspace.
```v
hp.delete_workspace('old_workspace')!
```
## Workspace Methods
### Directory Management
#### `ws.add_directory(path: string, name: string, scan: bool) !&Directory`
Adds a directory to the workspace.
```v
mut dir := ws.add_directory(
path: '/path/to/code'
name: 'my_code'
scan: true // Automatically scans all files
)!
```
#### `ws.list_directories() []&Directory`
Lists all directories in the workspace.
```v
dirs := ws.list_directories()
for dir in dirs {
println('Directory: ${dir.name}')
}
```
#### `ws.remove_directory(id: string) !`
Removes a directory from the workspace.
```v
ws.remove_directory(id: dir.id)!
```
### Prompt Generation
#### `ws.generate_prompt(instruction: string, selected_files: []string, show_all_files: bool) !string`
Generates a complete AI prompt with file map, contents, and instructions.
```v
// Use selected files (from select_file() calls)
prompt := ws.generate_prompt(
instruction: 'Review the code'
)!
// Or specify files explicitly
prompt2 := ws.generate_prompt(
instruction: 'Analyze these files'
selected_files: ['/path/to/file1.v', '/path/to/file2.v']
show_all_files: false
)!
```
#### `ws.generate_file_map(selected_files: []string, show_all: bool) !string`
Generates a hierarchical tree structure of files.
```v
file_map := ws.generate_file_map(
selected_files: ['/path/to/file1.v']
show_all: false
)!
println(file_map)
```
#### `ws.generate_file_contents(selected_files: []string, include_path: bool) !string`
Generates formatted file contents.
```v
contents := ws.generate_file_contents(
selected_files: ['/path/to/file1.v']
include_path: true
)!
println(contents)
```
## Directory Methods
### File Selection
#### `dir.select_file(path: string) !`
Marks a file as selected.
```v
dir.select_file(path: '/path/to/file.v')!
```
#### `dir.select_all() !`
Selects all files in the directory and subdirectories.
```v
dir.select_all()!
```
#### `dir.deselect_file(path: string) !`
Deselects a file.
```v
dir.deselect_file(path: '/path/to/file.v')!
```
#### `dir.deselect_all() !`
Deselects all files in the directory.
```v
dir.deselect_all()!
```
### Directory Information
#### `dir.exists() bool`
Checks if the directory exists on the filesystem.
```v
if dir.exists() {
println('Directory exists')
}
```
#### `dir.get_contents() !DirectoryContent`
Gets all files in the directory (scans if needed).
```v
content := dir.get_contents()!
println('Files: ${content.files.len}')
```
## Generated Prompt Format
The generated prompt uses a template with three sections:
```prompt
<user_instructions>
Review these files and suggest improvements
</user_instructions>
<file_map>
my_project/
├── src/
│ ├── main.v *
│ └── utils.v *
└── README.md *
</file_map>
<file_contents>
File: /path/to/src/main.v
\```v
module main
fn main() {
println('Hello')
}
\```
</file_contents>
```
Files marked with `*` in the file_map are the selected files included in the prompt.
## Complete Example
```v
import freeflowuniverse.herolib.develop.heroprompt
mut hp := heroprompt.get(name: 'my_app', create: true)!
mut ws := hp.new_workspace(name: 'backend')!
mut src_dir := ws.add_directory(path: '/path/to/src', name: 'source', scan: true)!
src_dir.select_file(path: '/path/to/src/main.v')!
prompt := ws.generate_prompt(instruction: 'Review the code')!
println(prompt)
heroprompt.delete(name: 'my_app')!
```
## Tips
- Use `heroprompt.delete()` at start for fresh state
- First workspace is automatically active
- Changes auto-save to Redis
- Use `scan: true` to discover all files
- Create separate workspaces for different contexts

View File

@@ -0,0 +1,198 @@
# HeroPrompt Example
Generate structured AI prompts from your codebase with file selection and workspace management.
## Quick Start
Run the example:
```bash
./examples/develop/heroprompt/prompt_example.vsh
```
This example demonstrates the complete workflow from creating a workspace to generating AI prompts.
---
## What is HeroPrompt?
HeroPrompt helps you organize code files and generate structured prompts for AI analysis:
- **Workspace Management**: Organize files into logical workspaces
- **File Selection**: Select specific files or entire directories
- **Prompt Generation**: Generate formatted prompts with file trees and contents
- **Redis Persistence**: All data persists across sessions
- **Active Workspace**: Easily switch between different workspaces
---
## Basic Usage
### 1. Create Instance and Workspace
```v
import freeflowuniverse.herolib.develop.heroprompt
// Create or get instance
mut hp := heroprompt.get(name: 'my_project', create: true)!
// Create workspace (first workspace is automatically active)
mut workspace := hp.new_workspace(
name: 'my_workspace'
description: 'My project workspace'
)!
```
### 2. Add Directories
```v
// Add directory and scan all files
mut dir := workspace.add_directory(
path: '/path/to/your/code'
name: 'my_code'
scan: true // Automatically scans all files and subdirectories
)!
```
### 3. Select Files
```v
// Select specific files
dir.select_file(path: '/path/to/file1.v')!
dir.select_file(path: '/path/to/file2.v')!
// Or select all files in directory
dir.select_all()!
```
### 4. Generate Prompt
```v
// Generate AI prompt with selected files
prompt := workspace.generate_prompt(
instruction: 'Review these files and suggest improvements'
)!
println(prompt)
```
---
## Generated Prompt Format
The generated prompt includes three sections:
```
<user_instructions>
Review these files and suggest improvements
</user_instructions>
<file_map>
my_project/
├── src/
│ ├── main.v *
│ └── utils.v *
└── README.md *
</file_map>
<file_contents>
File: /path/to/src/main.v
```v
module main
...
```
</file_contents>
```
---
## API Reference
### Factory Functions
```v
heroprompt.get(name: 'my_project', create: true)! // Get or create
heroprompt.delete(name: 'my_project')! // Delete instance
heroprompt.exists(name: 'my_project')! // Check if exists
heroprompt.list()! // List all instances
```
### HeroPrompt Methods
```v
hp.new_workspace(name: 'ws', description: 'desc')! // Create workspace
hp.get_workspace('ws')! // Get workspace by name
hp.list_workspaces() // List all workspaces
hp.delete_workspace('ws')! // Delete workspace
hp.get_active_workspace()! // Get active workspace
hp.set_active_workspace('ws')! // Set active workspace
```
### Workspace Methods
```v
ws.add_directory(path: '/path', name: 'dir', scan: true)! // Add directory
ws.list_directories() // List directories
ws.remove_directory(id: 'dir_id')! // Remove directory
ws.generate_prompt(instruction: 'Review')! // Generate prompt
ws.generate_file_map()! // Generate file tree
ws.generate_file_contents()! // Generate contents
```
### Directory Methods
```v
dir.select_file(path: '/path/to/file')! // Select file
dir.select_all()! // Select all files
dir.deselect_file(path: '/path/to/file')! // Deselect file
dir.deselect_all()! // Deselect all files
```
---
## Features
### Active Workspace
```v
// Get the currently active workspace
mut active := hp.get_active_workspace()!
// Switch to a different workspace
hp.set_active_workspace('other_workspace')!
```
### Multiple Workspaces
```v
// Create multiple workspaces for different purposes
mut backend := hp.new_workspace(name: 'backend')!
mut frontend := hp.new_workspace(name: 'frontend')!
mut docs := hp.new_workspace(name: 'documentation')!
```
### File Selection
```v
// Select individual files
dir.select_file(path: '/path/to/file.v')!
// Select all files in directory
dir.select_all()!
// Deselect files
dir.deselect_file(path: '/path/to/file.v')!
dir.deselect_all()!
```
---
## Tips
- Always start with cleanup (`heroprompt.delete()`) in examples to ensure a fresh state
- The first workspace created is automatically set as active
- File selection persists to Redis automatically
- Use `scan: true` when adding directories to automatically scan all files
- Selected files are tracked per directory for efficient management

View File

@@ -1,53 +0,0 @@
#!/usr/bin/env -S v -n -w -gc none -cg -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.develop.heroprompt
import freeflowuniverse.herolib.develop.codewalker
import freeflowuniverse.herolib.core.pathlib
import os
// Comprehensive example demonstrating heroprompt with codewalker integration
mut workspace := heroprompt.get(
name: 'hero'
create: true
)!
println(workspace)
// println('workspace (initial): ${workspace}')
// workspace.delete_workspace()!
// // println('selected (initial): ${workspace.selected_children()}')
// println('workspace (initial): ${workspace}')
// // Add a directory and a file
// // workspace.add_dir(
// // path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks'
// // )!
// // workspace.add_file(
// // path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks/participants.md'
// // )!
// // println('selected (after add): ${workspace.selected_children()}')
// // Build a prompt from current selection (should be empty now)
// mut prompt := workspace.prompt(
// text: 'Using the selected files, i want you to get all print statments'
// )
// println('--- PROMPT START ---')
// println(prompt)
// println('--- PROMPT END ---')
// // // Remove the file by name, then the directory by name
// // workspace.remove_file(name: 'docker_ubuntu_install.sh') or { println('remove_file: ${err}') }
// // workspace.remove_dir(name: 'docker') or { println('remove_dir: ${err}') }
// // println('selected (after remove): ${workspace.selected_children()}')
// // // List workspaces (names only)
// // mut all := heroprompt.list_workspaces() or { []&heroprompt.Workspace{} }
// // mut names := []string{}
// // for w in all {
// // names << w.name
// // }
// // println('workspaces: ${names}')
// // // Optionally delete the example workspace
// // workspace.delete_workspace() or { println('delete_workspace: ${err}') }

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env -S v -n -w -gc none -cg -cc tcc -d use_openssl -enable-globals run
import freeflowuniverse.herolib.develop.heroprompt
import os
println('=== HeroPrompt: AI Prompt Generation Example ===\n')
// ============================================================================
// STEP 1: Cleanup and Setup
// ============================================================================
// Always start fresh - delete any existing instance
println('Step 1: Cleaning up any existing instance...')
heroprompt.delete(name: 'prompt_demo') or {}
println(' Cleanup complete\n')
// ============================================================================
// STEP 2: Create HeroPrompt Instance
// ============================================================================
// Get or create a new HeroPrompt instance
// The 'create: true' parameter will create it if it doesn't exist
println('Step 2: Creating HeroPrompt instance...')
mut hp := heroprompt.get(name: 'prompt_demo', create: true)!
println(' Created instance: ${hp.name}\n')
// ============================================================================
// STEP 3: Create Workspace
// ============================================================================
// A workspace is a collection of directories and files
// The first workspace is automatically set as active
println('Step 3: Creating workspace...')
mut workspace := hp.new_workspace(
name: 'my_project'
description: 'Example project workspace'
)!
println(' Created workspace: ${workspace.name}')
println(' Active: ${workspace.is_active}')
println(' Description: ${workspace.description}\n')
// ============================================================================
// STEP 4: Add Directories to Workspace
// ============================================================================
// Add directories containing code you want to analyze
// The 'scan: true' parameter automatically scans all files and subdirectories
println('Step 4: Adding directories to workspace...')
homepath := os.home_dir()
// Add the examples directory
mut examples_dir := workspace.add_directory(
path: '${homepath}/code/github/freeflowuniverse/herolib/examples/develop/heroprompt'
name: 'examples'
scan: true
)!
println(' Added directory: examples')
// Add the library directory
mut lib_dir := workspace.add_directory(
path: '${homepath}/code/github/freeflowuniverse/herolib/lib/develop/heroprompt'
name: 'library'
scan: true
)!
println(' Added directory: library\n')
// ============================================================================
// STEP 5: Select Specific Files
// ============================================================================
// You can select specific files from directories for prompt generation
// This is useful when you only want to analyze certain files
println('Step 5: Selecting specific files...')
// Select individual files from the examples directory
examples_dir.select_file(
path: '${homepath}/code/github/freeflowuniverse/herolib/examples/develop/heroprompt/README.md'
)!
println(' Selected: README.md')
examples_dir.select_file(
path: '${homepath}/code/github/freeflowuniverse/herolib/examples/develop/heroprompt/prompt_example.vsh'
)!
println(' Selected: prompt_example.vsh')
// Select all files from the library directory
lib_dir.select_all()!
println(' Selected all files in library directory\n')
// ============================================================================
// STEP 6: Generate AI Prompt
// ============================================================================
// Generate a complete prompt with file map, file contents, and instructions
// The prompt automatically includes only the selected files
println('Step 6: Generating AI prompt...')
prompt := workspace.generate_prompt(
instruction: 'Review the selected files and provide suggestions for improvements.'
)!
println(' Generated prompt')
println(' Total length: ${prompt.len} characters\n')
// ============================================================================
// STEP 7: Display Prompt Preview
// ============================================================================
println('Step 7: Prompt preview (first 800 characters)...')
preview_len := if prompt.len > 800 { 800 } else { prompt.len }
println(prompt[..preview_len])
// ============================================================================
// STEP 8: Alternative - Get Active Workspace
// ============================================================================
// You can retrieve the active workspace without knowing its name
println('Step 8: Working with active workspace...')
mut active_ws := hp.get_active_workspace()!
println(' Retrieved active workspace: ${active_ws.name}')
println(' Directories: ${active_ws.directories.len}')
println(' Files: ${active_ws.files.len}\n')
// ============================================================================
// STEP 9: Set Different Active Workspace
// ============================================================================
// You can create multiple workspaces and switch between them
println('Step 9: Creating and switching workspaces...')
// Create a second workspace
mut workspace2 := hp.new_workspace(
name: 'documentation'
description: 'Documentation workspace'
is_active: false
)!
println(' Created workspace: ${workspace2.name}')
// Switch active workspace
hp.set_active_workspace('documentation')!
println(' Set active workspace to: documentation')
// Verify the switch
active_ws = hp.get_active_workspace()!
println(' Current active workspace: ${active_ws.name}\n')
// ============================================================================
// STEP 10: Cleanup
// ============================================================================
println('Step 10: Cleanup...')
heroprompt.delete(name: 'prompt_demo')!
println(' Deleted instance\n')

View File

@@ -18,6 +18,7 @@ pub fn build_selected_tree(files []string, base_root string) string {
}
rels << rp
}
rels.sort()
return tree_from_rel_paths(rels, '')
}

View File

@@ -1,8 +1,9 @@
!!hero_code.generate_client
name:'heroprompt'
classname:'Workspace'
singleton:0
classname:'HeroPrompt'
singleton:1
default:1
hasconfig:1
templates:
reset:0

View File

@@ -0,0 +1,126 @@
module heroprompt
import rand
import freeflowuniverse.herolib.data.ourtime
// HeroPrompt Methods - Workspace Management
@[params]
pub struct NewWorkspaceParams {
pub mut:
name string @[required] // Workspace name
description string // Optional description
is_active bool = false // Whether this should be the active workspace
}
// new_workspace creates a new workspace in this HeroPrompt instance
pub fn (mut hp HeroPrompt) new_workspace(args NewWorkspaceParams) !&Workspace {
hp.log(.info, 'Creating workspace: ${args.name}')
// Check if workspace already exists
if args.name in hp.workspaces {
hp.log(.error, 'Workspace already exists: ${args.name}')
return error('workspace already exists: ${args.name}')
}
// Determine if this should be the active workspace
// If it's the first workspace, make it active by default
// Or if explicitly requested via args.is_active
is_first_workspace := hp.workspaces.len == 0
should_be_active := args.is_active || is_first_workspace
// Create new workspace
mut ws := &Workspace{
id: rand.uuid_v4()
name: args.name
description: args.description
is_active: should_be_active
directories: map[string]&Directory{}
files: []HeropromptFile{}
created: ourtime.now()
updated: ourtime.now()
parent: &hp // Set parent reference for auto-save
}
// Add to heroprompt instance
hp.workspaces[args.name] = ws
hp.updated = ourtime.now()
// Save to Redis
hp.save()!
hp.log(.info, 'Workspace created: ${args.name}')
return ws
}
// get_workspace retrieves an existing workspace by name
pub fn (hp &HeroPrompt) get_workspace(name string) !&Workspace {
if name !in hp.workspaces {
return error('workspace not found: ${name}')
}
return hp.workspaces[name]
}
// list_workspaces returns all workspaces in this HeroPrompt instance
pub fn (hp &HeroPrompt) list_workspaces() []&Workspace {
mut workspaces := []&Workspace{}
for _, ws in hp.workspaces {
workspaces << ws
}
return workspaces
}
// delete_workspace removes a workspace from this HeroPrompt instance
pub fn (mut hp HeroPrompt) delete_workspace(name string) ! {
if name !in hp.workspaces {
hp.log(.error, 'Workspace not found: ${name}')
return error('workspace not found: ${name}')
}
hp.workspaces.delete(name)
hp.updated = ourtime.now()
hp.save()!
hp.log(.info, 'Workspace deleted: ${name}')
}
// save persists the HeroPrompt instance to Redis
pub fn (mut hp HeroPrompt) save() ! {
hp.updated = ourtime.now()
set(hp)!
}
// get_active_workspace returns the currently active workspace
pub fn (mut hp HeroPrompt) get_active_workspace() !&Workspace {
for name, ws in hp.workspaces {
if ws.is_active {
// Return the actual reference from the map, not a copy
return hp.workspaces[name] or { return error('workspace not found: ${name}') }
}
}
return error('no active workspace found')
}
// set_active_workspace sets the specified workspace as active and deactivates all others
pub fn (mut hp HeroPrompt) set_active_workspace(name string) ! {
// Check if workspace exists
if name !in hp.workspaces {
hp.log(.error, 'Workspace not found: ${name}')
return error('workspace not found: ${name}')
}
// Deactivate all workspaces
for _, mut ws in hp.workspaces {
ws.is_active = false
}
// Activate the specified workspace
mut ws := hp.workspaces[name] or { return error('workspace not found: ${name}') }
ws.is_active = true
hp.updated = ourtime.now()
// Save to Redis
hp.save()!
hp.log(.info, 'Active workspace set to: ${name}')
}

View File

@@ -1,66 +0,0 @@
module heroprompt
import freeflowuniverse.herolib.core.pathlib
pub struct HeropromptChild {
pub mut:
content string
path pathlib.Path
name string
include_tree bool // when true and this child is a dir, include full subtree in maps/contents
}
// Utility function to get file extension with special handling for common files
pub fn get_file_extension(filename string) string {
// Handle special cases for common files without extensions
special_files := {
'dockerfile': 'dockerfile'
'makefile': 'makefile'
'license': 'license'
'readme': 'readme'
'changelog': 'changelog'
'authors': 'authors'
'contributors': 'contributors'
'copying': 'copying'
'install': 'install'
'news': 'news'
'todo': 'todo'
'version': 'version'
'manifest': 'manifest'
'gemfile': 'gemfile'
'rakefile': 'rakefile'
'procfile': 'procfile'
'vagrantfile': 'vagrantfile'
}
lower_filename := filename.to_lower()
if lower_filename in special_files {
return special_files[lower_filename]
}
if filename.starts_with('.') && !filename.starts_with('..') {
if filename.contains('.') && filename.len > 1 {
parts := filename[1..].split('.')
if parts.len >= 2 {
return parts[parts.len - 1]
} else {
return filename[1..]
}
} else {
return filename[1..]
}
}
parts := filename.split('.')
if parts.len < 2 {
return ''
}
return parts[parts.len - 1]
}
// Read the file content
pub fn (chl HeropromptChild) read() !string {
if chl.path.cat != .file {
return error('cannot read content of a directory')
}
mut file_path := pathlib.get(chl.path.path)
content := file_path.read()!
return content
}

View File

@@ -0,0 +1,467 @@
module heroprompt
import os
import rand
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.develop.codewalker
import freeflowuniverse.herolib.data.ourtime
// Directory represents a directory/directory added to a workspace
// It contains metadata about the directory and its location
@[heap]
pub struct Directory {
pub mut:
id string = rand.uuid_v4() // Unique identifier for this directory
name string // Display name (can be customized by user)
path string // Absolute path to the directory
description string // Optional description
git_info GitInfo // Git directory information (if applicable)
created ourtime.OurTime // When this directory was added
updated ourtime.OurTime // Last update time
include_tree bool = true // Whether to include full tree in file maps
is_expanded bool // UI state: whether directory is expanded in tree view
is_selected bool // UI state: whether directory checkbox is checked
selected_files map[string]bool // Map of file paths to selection state (normalized paths)
}
// GitInfo contains git-specific metadata for a directory
pub struct GitInfo {
pub mut:
is_git_dir bool // Whether this is a git directory
current_branch string // Current git branch
remote_url string // Remote URL (if any)
last_commit string // Last commit hash
has_changes bool // Whether there are uncommitted changes
}
// Create a new directory from a directory path
@[params]
pub struct NewDirectoryParams {
pub mut:
path string @[required] // Absolute path to directory
name string // Optional custom name (defaults to directory name)
description string // Optional description
}
// Create a new directory instance
pub fn new_directory(args NewDirectoryParams) !Directory {
if args.path.len == 0 {
return error('directory path is required')
}
mut dir_path := pathlib.get(args.path)
if !dir_path.exists() || !dir_path.is_dir() {
return error('path is not an existing directory: ${args.path}')
}
abs_path := dir_path.realpath()
dir_name := dir_path.name()
// Detect git information
git_info := detect_git_info(abs_path)
return Directory{
id: rand.uuid_v4()
name: if args.name.len > 0 { args.name } else { dir_name }
path: abs_path
description: args.description
git_info: git_info
created: ourtime.now()
updated: ourtime.now()
include_tree: true
}
}
// Detect git information for a directory
fn detect_git_info(path string) GitInfo {
// TODO: Use the gittools library to get this information
// Keep it for now, maybe next version
mut info := GitInfo{
is_git_dir: false
}
// Check if .git directory exists
git_dir := os.join_path(path, '.git')
if !os.exists(git_dir) {
return info
}
info.is_git_dir = true
// Try to detect current branch
head_file := os.join_path(git_dir, 'HEAD')
if os.exists(head_file) {
head_content := os.read_file(head_file) or { '' }
if head_content.contains('ref: refs/heads/') {
info.current_branch = head_content.replace('ref: refs/heads/', '').trim_space()
}
}
// Try to detect remote URL
config_file := os.join_path(git_dir, 'config')
if os.exists(config_file) {
config_content := os.read_file(config_file) or { '' }
// Simple parsing - look for url = line
for line in config_content.split_into_lines() {
trimmed := line.trim_space()
if trimmed.starts_with('url = ') {
info.remote_url = trimmed.replace('url = ', '')
break
}
}
}
// Check for uncommitted changes (simplified - just check if there are any files in git status)
// In a real implementation, would run `git status --porcelain`
info.has_changes = false // Placeholder - would need to execute git command
return info
}
// Update directory metadata
@[params]
pub struct UpdateDirectoryParams {
pub mut:
name string
description string
}
pub fn (mut dir Directory) update(args UpdateDirectoryParams) {
if args.name.len > 0 {
dir.name = args.name
}
if args.description.len > 0 {
dir.description = args.description
}
dir.updated = ourtime.now()
}
// Refresh git information for this directory
pub fn (mut dir Directory) refresh_git_info() {
dir.git_info = detect_git_info(dir.path)
dir.updated = ourtime.now()
}
// Check if directory path still exists
pub fn (dir &Directory) exists() bool {
return os.exists(dir.path) && os.is_dir(dir.path)
}
// Get directory size (number of files)
pub fn (dir &Directory) file_count() !int {
if !dir.exists() {
return error('directory path no longer exists')
}
// Use codewalker to count files
mut cw := codewalker.new(codewalker.CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: dir.path, content_read: false)!
return fm.content.len
}
// Get display name with git branch if available
pub fn (dir &Directory) display_name() string {
if dir.git_info.is_git_dir && dir.git_info.current_branch.len > 0 {
return '${dir.name} (${dir.git_info.current_branch})'
}
return dir.name
}
// Directory Management Methods
// DirectoryContent holds the scanned files and directories from a directory
pub struct DirectoryContent {
pub mut:
files []HeropromptFile // All files found in the directory
directories []string // All directories found in the directory
file_count int // Total number of files
dir_count int // Total number of directories
}
// get_contents scans the directory and returns all files and directories
// This method respects .gitignore and .heroignore files
// This is a public method that can be used to retrieve directory contents for prompt generation
pub fn (dir &Directory) get_contents() !DirectoryContent {
return dir.scan()
}
// scan scans the entire directory and returns all files and directories
// This method respects .gitignore and .heroignore files
// Note: This is a private method. Use add_dir() with scan parameter or get_contents() instead.
fn (dir &Directory) scan() !DirectoryContent {
if !dir.exists() {
return error('directory path does not exist: ${dir.path}')
}
// Use codewalker to scan the directory with gitignore support
mut cw := codewalker.new(codewalker.CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: dir.path, content_read: true)!
mut files := []HeropromptFile{}
mut directories := map[string]bool{} // Use map to avoid duplicates
// Process each file from the filemap
for file_path, content in fm.content {
// Create HeropromptFile for each file
abs_path := os.join_path(dir.path, file_path)
file := HeropromptFile{
id: rand.uuid_v4()
name: os.base(file_path)
path: abs_path
content: content
created: ourtime.now()
updated: ourtime.now()
}
files << file
// Extract directory path
dir_path := os.dir(file_path)
if dir_path != '.' && dir_path.len > 0 {
// Add all parent directories
mut current_dir := dir_path
for current_dir != '.' && current_dir.len > 0 {
directories[current_dir] = true
current_dir = os.dir(current_dir)
}
}
}
// Convert directories map to array
mut dir_list := []string{}
for directory_path, _ in directories {
dir_list << directory_path
}
return DirectoryContent{
files: files
directories: dir_list
file_count: files.len
dir_count: dir_list.len
}
}
@[params]
pub struct AddFileParams {
pub mut:
path string @[required] // Path to file (relative to directory or absolute)
}
// add_file adds a specific file to the directory
// Returns the created HeropromptFile
pub fn (dir &Directory) add_file(args AddFileParams) !HeropromptFile {
mut file_path := args.path
// If path is relative, make it relative to directory path
if !os.is_abs_path(file_path) {
file_path = os.join_path(dir.path, file_path)
}
// Validate file exists
if !os.exists(file_path) {
return error('file does not exist: ${file_path}')
}
if os.is_dir(file_path) {
return error('path is a directory, not a file: ${file_path}')
}
// Read file content
content := os.read_file(file_path) or { return error('failed to read file: ${file_path}') }
// Create HeropromptFile
file := HeropromptFile{
id: rand.uuid_v4()
name: os.base(file_path)
path: file_path
content: content
created: ourtime.now()
updated: ourtime.now()
}
return file
}
@[params]
pub struct SelectFileParams {
pub mut:
path string @[required] // Path to file (relative to directory or absolute)
}
// select_file marks a file as selected within this directory
// The file path can be relative to the directory or absolute
pub fn (mut dir Directory) select_file(args SelectFileParams) ! {
// Normalize the path
mut file_path := args.path
if !os.is_abs_path(file_path) {
file_path = os.join_path(dir.path, file_path)
}
file_path = os.real_path(file_path)
// Verify file exists
if !os.exists(file_path) {
return error('file does not exist: ${args.path}')
}
// Verify file is within this directory
if !file_path.starts_with(os.real_path(dir.path)) {
return error('file is not within this directory: ${args.path}')
}
// Mark file as selected
dir.selected_files[file_path] = true
dir.updated = ourtime.now()
}
// select_all marks all files in this directory and subdirectories as selected
pub fn (mut dir Directory) select_all() ! {
// Verify directory exists
if !dir.exists() {
return error('directory does not exist: ${dir.path}')
}
// Get all files in directory
content := dir.get_contents()!
// Mark all files as selected
for file in content.files {
normalized_path := os.real_path(file.path)
dir.selected_files[normalized_path] = true
}
dir.updated = ourtime.now()
}
@[params]
pub struct DeselectFileParams {
pub mut:
path string @[required] // Path to file (relative to directory or absolute)
}
// deselect_file marks a file as not selected within this directory
pub fn (mut dir Directory) deselect_file(args DeselectFileParams) ! {
// Normalize the path
mut file_path := args.path
if !os.is_abs_path(file_path) {
file_path = os.join_path(dir.path, file_path)
}
file_path = os.real_path(file_path)
// Verify file exists
if !os.exists(file_path) {
return error('file does not exist: ${args.path}')
}
// Verify file is within this directory
if !file_path.starts_with(os.real_path(dir.path)) {
return error('file is not within this directory: ${args.path}')
}
// Mark file as not selected (remove from map or set to false)
dir.selected_files.delete(file_path)
dir.updated = ourtime.now()
}
// deselect_all marks all files in this directory as not selected
pub fn (mut dir Directory) deselect_all() ! {
// Verify directory exists
if !dir.exists() {
return error('directory does not exist: ${dir.path}')
}
// Clear all selections
dir.selected_files.clear()
dir.updated = ourtime.now()
}
// expand sets the directory as expanded in the UI
pub fn (mut dir Directory) expand() {
dir.is_expanded = true
dir.updated = ourtime.now()
}
// collapse sets the directory as collapsed in the UI
pub fn (mut dir Directory) collapse() {
dir.is_expanded = false
dir.updated = ourtime.now()
}
@[params]
pub struct AddDirParams {
pub mut:
path string @[required] // Path to directory (relative to directory or absolute)
scan bool = true // Whether to automatically scan the directory (default: true)
}
// add_dir adds all files from a specific directory
// Returns DirectoryContent with files from that directory
// If scan=true (default), automatically scans the directory respecting .gitignore
// If scan=false, returns empty DirectoryContent (manual mode)
pub fn (dir &Directory) add_dir(args AddDirParams) !DirectoryContent {
mut dir_path := args.path
// If path is relative, make it relative to directory path
if !os.is_abs_path(dir_path) {
dir_path = os.join_path(dir.path, dir_path)
}
// Validate directory exists
if !os.exists(dir_path) {
return error('directory does not exist: ${dir_path}')
}
if !os.is_dir(dir_path) {
return error('path is not a directory: ${dir_path}')
}
// If scan is false, return empty content (manual mode)
if !args.scan {
return DirectoryContent{
files: []HeropromptFile{}
file_count: 0
dir_count: 0
}
}
// Use codewalker to scan the directory
mut cw := codewalker.new(codewalker.CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: dir_path, content_read: true)!
mut files := []HeropromptFile{}
mut directories := map[string]bool{} // Track directories
// Process each file
for file_path, content in fm.content {
abs_path := os.join_path(dir_path, file_path)
file := HeropromptFile{
id: rand.uuid_v4()
name: os.base(file_path)
path: abs_path
content: content
created: ourtime.now()
updated: ourtime.now()
}
files << file
// Extract directory path
dir_part := os.dir(file_path)
if dir_part != '.' && dir_part.len > 0 {
// Add all parent directories
mut current_dir := dir_part
for current_dir != '.' && current_dir.len > 0 {
directories[current_dir] = true
current_dir = os.dir(current_dir)
}
}
}
// Convert directories map to array
mut dir_list := []string{}
for directory_path, _ in directories {
dir_list << directory_path
}
return DirectoryContent{
files: files
directories: dir_list
file_count: files.len
dir_count: dir_list.len
}
}

View File

@@ -0,0 +1,289 @@
module heroprompt
import os
// Test directory: scan entire directory
fn test_directory_scan() ! {
// Create temp directory with files
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_scan')
sub_dir := os.join_path(test_dir, 'src')
os.mkdir_all(sub_dir)!
os.write_file(os.join_path(test_dir, 'readme.md'), '# Test')!
os.write_file(os.join_path(test_dir, 'main.v'), 'fn main() {}')!
os.write_file(os.join_path(sub_dir, 'utils.v'), 'pub fn hello() {}')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Scan directory
content := repo.scan()!
// Should find all files
assert content.file_count >= 3
assert content.files.len >= 3
// Should find directories
assert content.dir_count >= 1
// Verify files have content
mut found_readme := false
for file in content.files {
if file.name == 'readme.md' {
found_readme = true
assert file.content.contains('# Test')
}
}
assert found_readme, 'readme.md not found in scanned files'
}
// Test directory: scan respects gitignore
fn test_directory_scan_gitignore() ! {
// Create temp directory with .gitignore
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_gitignore')
os.mkdir_all(test_dir)!
// Create .gitignore
os.write_file(os.join_path(test_dir, '.gitignore'), 'ignored.txt\n*.log')!
// Create files
os.write_file(os.join_path(test_dir, 'included.txt'), 'included')!
os.write_file(os.join_path(test_dir, 'ignored.txt'), 'ignored')!
os.write_file(os.join_path(test_dir, 'test.log'), 'log')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Scan directory
content := repo.scan()!
// Should include included.txt
mut found_included := false
mut found_ignored := false
mut found_log := false
for file in content.files {
if file.name == 'included.txt' {
found_included = true
}
if file.name == 'ignored.txt' {
found_ignored = true
}
if file.name == 'test.log' {
found_log = true
}
}
assert found_included, 'included.txt should be found'
// Note: gitignore behavior depends on codewalker implementation
// These assertions might need adjustment based on actual behavior
}
// Test directory: add_file with relative path
fn test_directory_add_file_relative() ! {
// Create temp directory with file
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_file_rel')
os.mkdir_all(test_dir)!
os.write_file(os.join_path(test_dir, 'test.txt'), 'test content')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Add file with relative path
file := repo.add_file(path: 'test.txt')!
assert file.name == 'test.txt'
assert file.content == 'test content'
assert file.path.contains('test.txt')
}
// Test directory: add_file with absolute path
fn test_directory_add_file_absolute() ! {
// Create temp directory with file
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_file_abs')
os.mkdir_all(test_dir)!
test_file := os.join_path(test_dir, 'test.txt')
os.write_file(test_file, 'test content')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Add file with absolute path
file := repo.add_file(path: test_file)!
assert file.name == 'test.txt'
assert file.content == 'test content'
}
// Test directory: add_file non-existent file
fn test_directory_add_file_nonexistent() ! {
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_nonexist')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Try to add non-existent file
repo.add_file(path: 'nonexistent.txt') or {
assert err.msg().contains('does not exist')
return
}
assert false, 'Expected error when adding non-existent file'
}
// Test directory: add_dir with relative path
fn test_directory_add_dir_relative() ! {
// Create temp directory with subdirectory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_dir_rel')
sub_dir := os.join_path(test_dir, 'src')
os.mkdir_all(sub_dir)!
os.write_file(os.join_path(sub_dir, 'file1.txt'), 'content1')!
os.write_file(os.join_path(sub_dir, 'file2.txt'), 'content2')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Add directory with relative path
content := repo.add_dir(path: 'src')!
assert content.file_count >= 2
assert content.files.len >= 2
}
// Test directory: add_dir with absolute path
fn test_directory_add_dir_absolute() ! {
// Create temp directory with subdirectory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_dir_abs')
sub_dir := os.join_path(test_dir, 'src')
os.mkdir_all(sub_dir)!
os.write_file(os.join_path(sub_dir, 'file1.txt'), 'content1')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Add directory with absolute path
content := repo.add_dir(path: sub_dir)!
assert content.file_count >= 1
}
// Test directory: add_dir non-existent directory
fn test_directory_add_dir_nonexistent() ! {
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_add_dir_nonexist')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Try to add non-existent directory
repo.add_dir(path: 'nonexistent_dir') or {
assert err.msg().contains('does not exist')
return
}
assert false, 'Expected error when adding non-existent directory'
}
// Test directory: file_count
fn test_directory_file_count() ! {
// Create temp directory with files
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_file_count')
os.mkdir_all(test_dir)!
os.write_file(os.join_path(test_dir, 'file1.txt'), 'content1')!
os.write_file(os.join_path(test_dir, 'file2.txt'), 'content2')!
os.write_file(os.join_path(test_dir, 'file3.txt'), 'content3')!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir)!
// Get file count
count := repo.file_count()!
assert count >= 3
}
// Test directory: display_name with git info
fn test_directory_display_name() ! {
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_display_name')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
}
// Create directory
repo := new_directory(path: test_dir, name: 'Test Repo')!
// Display name should be the custom name
display_name := repo.display_name()
assert display_name == 'Test Repo'
}
// Test directory: exists check
fn test_directory_exists() ! {
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_exists')
os.mkdir_all(test_dir)!
// Create directory
repo := new_directory(path: test_dir)!
// Should exist
assert repo.exists() == true
// Remove directory
os.rmdir_all(test_dir)!
// Should not exist
assert repo.exists() == false
}

View File

@@ -3,10 +3,9 @@ module heroprompt
import freeflowuniverse.herolib.core.base
import freeflowuniverse.herolib.core.playbook { PlayBook }
import json
import time
__global (
heroprompt_global map[string]&Workspace
heroprompt_global shared map[string]&HeroPrompt
heroprompt_default string
)
@@ -15,55 +14,81 @@ __global (
@[params]
pub struct ArgsGet {
pub mut:
name string = 'default'
path string
fromdb bool // will load from filesystem
create bool // default will not create if not exist
name string = 'default' // HeroPrompt instance name
fromdb bool // Load from Redis (default: false, uses in-memory cache)
create bool // Create if doesn't exist (default: false, returns error if not found)
reset bool // Delete and recreate if exists (default: false)
}
pub fn new(args ArgsGet) !&Workspace {
mut obj := Workspace{
name: args.name
children: []
created: time.now()
updated: time.now()
is_saved: true
// get retrieves or creates a HeroPrompt instance
// This is the main entry point for accessing HeroPrompt instances
pub fn get(args ArgsGet) !&HeroPrompt {
mut context := base.context()!
mut r := context.redis()!
// Handle reset: delete existing instance and create fresh
if args.reset {
// Delete from Redis and memory
r.hdel('context:heroprompt', args.name) or {}
lock heroprompt_global {
heroprompt_global.delete(args.name)
}
// Create new instance
mut obj := HeroPrompt{
name: args.name
}
set(obj)!
return get(name: args.name)! // Recursive call to load the new instance
}
set(obj)!
return get(name: args.name)!
}
// Check if we need to load from DB
needs_load := rlock heroprompt_global {
args.fromdb || args.name !in heroprompt_global
}
if needs_load {
heroprompt_default = args.name
pub fn get(args ArgsGet) !&Workspace {
mut context := base.context()!
heroprompt_default = args.name
if args.fromdb || args.name !in heroprompt_global {
mut r := context.redis()!
if r.hexists('context:heroprompt', args.name)! {
// Load existing instance from Redis
data := r.hget('context:heroprompt', args.name)!
if data.len == 0 {
return error('Workspace with name: heroprompt does not exist, prob bug.')
print_backtrace()
return error('HeroPrompt with name: ${args.name} does not exist, prob bug.')
}
mut obj := json.decode(Workspace, data)!
mut obj := json.decode(HeroPrompt, data)!
set_in_mem(obj)!
} else {
// Instance doesn't exist in Redis
if args.create {
new(args)!
// Create new instance
mut obj := HeroPrompt{
name: args.name
}
set(obj)!
} else {
return error("Workspace with name 'heroprompt' does not exist")
print_backtrace()
return error("HeroPrompt with name '${args.name}' does not exist")
}
}
return get(name: args.name)! // no longer from db nor create
return get(name: args.name)! // Recursive call to return the instance
}
return heroprompt_global[args.name] or {
return error('could not get config for heroprompt with name:heroprompt')
// Return from in-memory cache
return rlock heroprompt_global {
heroprompt_global[args.name] or {
print_backtrace()
return error('could not get config for heroprompt with name: ${args.name}')
}
}
}
// register the config for the future
pub fn set(o Workspace) ! {
pub fn set(o HeroPrompt) ! {
mut o2 := set_in_mem(o)!
heroprompt_default = o2.name
mut context := base.context()!
mut r := context.redis()!
r.hset('context:heroprompt', o2.name, json.encode(o2))!
@@ -80,6 +105,11 @@ pub fn delete(args ArgsGet) ! {
mut context := base.context()!
mut r := context.redis()!
r.hdel('context:heroprompt', args.name)!
// Also remove from memory
lock heroprompt_global {
heroprompt_global.delete(args.name)
}
}
@[params]
@@ -89,15 +119,17 @@ pub mut:
}
// if fromdb set: load from filesystem, and not from mem, will also reset what is in mem
pub fn list(args ArgsList) ![]&Workspace {
mut res := []&Workspace{}
pub fn list(args ArgsList) ![]&HeroPrompt {
mut res := []&HeroPrompt{}
mut context := base.context()!
if args.fromdb {
// reset what is in mem
heroprompt_global = map[string]&Workspace{}
lock heroprompt_global {
heroprompt_global = map[string]&HeroPrompt{}
}
heroprompt_default = ''
}
if args.fromdb {
mut r := context.redis()!
mut l := r.hkeys('context:heroprompt')!
@@ -107,18 +139,34 @@ pub fn list(args ArgsList) ![]&Workspace {
return res
} else {
// load from memory
for _, client in heroprompt_global {
res << client
rlock heroprompt_global {
for _, client in heroprompt_global {
res << client
}
}
}
return res
}
// only sets in mem, does not set as config
fn set_in_mem(o Workspace) !Workspace {
fn set_in_mem(o HeroPrompt) !HeroPrompt {
mut o2 := obj_init(o)!
heroprompt_global[o2.name] = &o2
// Restore parent references for all workspaces AFTER storing in global
// This ensures the parent pointer points to the actual instance in memory
lock heroprompt_global {
heroprompt_global[o2.name] = &o2
// Now restore parent references using the stored instance
mut stored := heroprompt_global[o2.name] or {
return error('failed to store heroprompt instance in memory')
}
for _, mut ws in stored.workspaces {
ws.parent = stored
}
}
heroprompt_default = o2.name
return o2
}
@@ -126,32 +174,17 @@ pub fn play(mut plbook PlayBook) ! {
if !plbook.exists(filter: 'heroprompt.') {
return
}
// 1) Configure workspaces
mut cfg_actions := plbook.find(filter: 'heroprompt.configure')!
for cfg_action in cfg_actions {
heroscript := cfg_action.heroscript()
mut obj := heroscript_loads(heroscript)!
set(obj)!
}
// 2) Add directories
for action in plbook.find(filter: 'heroprompt.add_dir')! {
mut p := action.params
wsname := p.get_default('name', heroprompt_default)!
mut wsp := get(name: wsname)!
path := p.get('path') or { return error("heroprompt.add_dir requires 'path'") }
wsp.add_dir(path: path)!
}
// 3) Add files
for action in plbook.find(filter: 'heroprompt.add_file')! {
mut p := action.params
wsname := p.get_default('name', heroprompt_default)!
mut wsp := get(name: wsname)!
path := p.get('path') or { return error("heroprompt.add_file requires 'path'") }
wsp.add_file(path: path)!
mut install_actions := plbook.find(filter: 'heroprompt.configure')!
if install_actions.len > 0 {
for mut install_action in install_actions {
heroscript := install_action.heroscript()
mut obj2 := heroscript_loads(heroscript)!
set(obj2)!
install_action.done = true
}
}
}
// switch instance to be used for heroprompt
pub fn switch(name string) {
heroprompt_default = name
}

View File

@@ -0,0 +1,118 @@
module heroprompt
import rand
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.data.ourtime
// HeropromptFile represents a standalone file added to a workspace
// (not part of a directory)
@[heap]
pub struct HeropromptFile {
pub mut:
id string = rand.uuid_v4() // Unique identifier
name string // File name
path string // Absolute path to file
content string // File content (cached)
created ourtime.OurTime // When added to workspace
updated ourtime.OurTime // Last update time
is_selected bool // UI state: whether file checkbox is checked
}
// Create a new file instance
@[params]
pub struct NewFileParams {
pub mut:
path string @[required] // Absolute path to file
}
pub fn new_file(args NewFileParams) !HeropromptFile {
if args.path.len == 0 {
return error('file path is required')
}
mut file_path := pathlib.get(args.path)
if !file_path.exists() || !file_path.is_file() {
return error('path is not an existing file: ${args.path}')
}
abs_path := file_path.realpath()
file_name := file_path.name()
// Read file content
content := file_path.read() or { '' }
return HeropromptFile{
id: rand.uuid_v4()
name: file_name
path: abs_path
content: content
created: ourtime.now()
updated: ourtime.now()
}
}
// Refresh file content from disk
pub fn (mut file HeropromptFile) refresh() ! {
mut file_path := pathlib.get(file.path)
if !file_path.exists() {
return error('file no longer exists: ${file.path}')
}
file.content = file_path.read()!
file.updated = ourtime.now()
}
// Check if file still exists
pub fn (file &HeropromptFile) exists() bool {
mut file_path := pathlib.get(file.path)
return file_path.exists() && file_path.is_file()
}
// Get file extension
pub fn (file &HeropromptFile) extension() string {
return get_file_extension(file.name)
}
// Utility function to get file extension with special handling for common files
pub fn get_file_extension(filename string) string {
// Handle special cases for common files without extensions
special_files := {
'dockerfile': 'dockerfile'
'makefile': 'makefile'
'license': 'license'
'readme': 'readme'
'changelog': 'changelog'
'authors': 'authors'
'contributors': 'contributors'
'copying': 'copying'
'install': 'install'
'news': 'news'
'todo': 'todo'
'version': 'version'
'manifest': 'manifest'
'gemfile': 'gemfile'
'rakefile': 'rakefile'
'procfile': 'procfile'
'vagrantfile': 'vagrantfile'
}
lower_filename := filename.to_lower()
if lower_filename in special_files {
return special_files[lower_filename]
}
if filename.starts_with('.') && !filename.starts_with('..') {
if filename.contains('.') && filename.len > 1 {
parts := filename[1..].split('.')
if parts.len >= 2 {
return parts[parts.len - 1]
} else {
return filename[1..]
}
} else {
return filename[1..]
}
}
parts := filename.split('.')
if parts.len < 2 {
return ''
}
return parts[parts.len - 1]
}

View File

@@ -0,0 +1,150 @@
module heroprompt
import os
// Test file: create new file
fn test_new_file() ! {
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_new_file.txt')
os.write_file(test_file, 'test content')!
defer {
os.rm(test_file) or {}
}
// Create HeropromptFile
file := new_file(path: test_file)!
assert file.name == 'test_heroprompt_new_file.txt'
assert file.content == 'test content'
assert file.path == os.real_path(test_file)
assert file.id.len > 0
}
// Test file: create file with non-existent path
fn test_new_file_nonexistent() ! {
// Try to create file with non-existent path
new_file(path: '/nonexistent/path/file.txt') or {
assert err.msg().contains('not an existing file')
return
}
assert false, 'Expected error when creating file with non-existent path'
}
// Test file: refresh content
fn test_file_refresh() ! {
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_refresh.txt')
os.write_file(test_file, 'initial content')!
defer {
os.rm(test_file) or {}
}
// Create HeropromptFile
mut file := new_file(path: test_file)!
assert file.content == 'initial content'
// Modify file on disk
os.write_file(test_file, 'updated content')!
// Refresh file
file.refresh()!
assert file.content == 'updated content'
}
// Test file: exists check
fn test_file_exists() ! {
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_file_exists.txt')
os.write_file(test_file, 'content')!
// Create HeropromptFile
file := new_file(path: test_file)!
// Should exist
assert file.exists() == true
// Remove file
os.rm(test_file)!
// Should not exist
assert file.exists() == false
}
// Test file: get extension
fn test_file_extension() ! {
// Create temp files with different extensions
temp_base := os.temp_dir()
test_files := {
'test.txt': 'txt'
'test.v': 'v'
'test.py': 'py'
'archive.tar.gz': 'gz'
'.gitignore': 'gitignore'
'Dockerfile': 'dockerfile'
'Makefile': 'makefile'
'README': 'readme'
}
for filename, expected_ext in test_files {
test_file := os.join_path(temp_base, filename)
os.write_file(test_file, 'content')!
file := new_file(path: test_file)!
actual_ext := file.extension()
assert actual_ext == expected_ext, 'Expected ${expected_ext} for ${filename}, got ${actual_ext}'
os.rm(test_file)!
}
}
// Test get_file_extension utility function
fn test_get_file_extension() ! {
// Regular files
assert get_file_extension('test.txt') == 'txt'
assert get_file_extension('main.v') == 'v'
assert get_file_extension('script.py') == 'py'
// Files with multiple dots
assert get_file_extension('archive.tar.gz') == 'gz'
assert get_file_extension('config.test.js') == 'js'
// Dotfiles
assert get_file_extension('.gitignore') == 'gitignore'
assert get_file_extension('.env') == 'env'
// Special files
assert get_file_extension('Dockerfile') == 'dockerfile'
assert get_file_extension('Makefile') == 'makefile'
assert get_file_extension('README') == 'readme'
assert get_file_extension('LICENSE') == 'license'
// Files without extension
assert get_file_extension('noextension') == ''
}
// Test file: generate UUIDs
fn test_file_unique_ids() ! {
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_unique_id.txt')
os.write_file(test_file, 'content')!
defer {
os.rm(test_file) or {}
}
// Create HeropromptFile instance
file1 := new_file(path: test_file)!
// ID should be a UUID (36 characters with dashes)
assert file1.id.len == 36
assert file1.id.contains('-')
}

View File

@@ -0,0 +1,48 @@
module heroprompt
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.core.logger
// log writes a log message with the specified level
// Outputs to both console and log file (unless run_in_tests is true)
pub fn (mut hp HeroPrompt) log(level LogLevel, text string) {
// Skip logging if running in tests
if hp.run_in_tests {
return
}
// Console output with appropriate colors
match level {
.error {
console.print_stderr('ERROR: ${text}')
}
.warning {
console.print_warn('WARNING: ${text}')
}
.info {
console.print_info(text)
}
.debug {
console.print_debug(text)
}
}
// File logging - use the stored logger instance (no resource leak)
level_str := match level {
.error { 'ERROR' }
.warning { 'WARNING' }
.info { 'INFO' }
.debug { 'DEBUG' }
}
logtype := match level {
.error { logger.LogType.error }
else { logger.LogType.stdout }
}
hp.logger.log(
cat: level_str
log: text
logtype: logtype
) or { console.print_stderr('Failed to write log: ${err}') }
}

View File

@@ -1,48 +1,94 @@
module heroprompt
import time
import freeflowuniverse.herolib.core.playbook
import freeflowuniverse.herolib.data.encoderhero
import freeflowuniverse.herolib.data.ourtime
import freeflowuniverse.herolib.core.logger
import rand
pub const version = '1.0.0'
const singleton = false
const default = true
// Workspace represents a workspace containing multiple directories
// and their selected files for AI prompt generation
// LogLevel represents the severity level of a log message
pub enum LogLevel {
error
warning
info
debug
}
// HeroPrompt is the main factory instance that manages workspaces
@[heap]
pub struct HeroPrompt {
pub mut:
id string = rand.uuid_v4() // Unique identifier
name string = 'default' // Instance name
workspaces map[string]&Workspace // Map of workspace name to workspace
created ourtime.OurTime // Time of creation
updated ourtime.OurTime // Time of last update
log_path string // Path to log file
run_in_tests bool // Flag to suppress logging during tests
logger logger.Logger @[skip; str: skip] // Logger instance (reused, not serialized)
}
// Workspace represents a collection of directories and files for prompt generation
@[heap]
pub struct Workspace {
pub mut:
name string = 'default' // Workspace name
children []HeropromptChild // List of directories and files in this workspace
created time.Time // Time of creation
updated time.Time // Time of last update
is_saved bool
id string // Unique identifier
name string // Workspace name (required)
description string // Workspace description (optional)
is_active bool // Whether this is the active workspace
directories map[string]&Directory // Map of directory ID to directory
files []HeropromptFile // Standalone files in this workspace
created ourtime.OurTime // Time of creation
updated ourtime.OurTime // Time of last update
parent &HeroPrompt @[skip; str: skip] // Reference to parent HeroPrompt (not serialized)
}
// your checking & initialization code if needed
fn obj_init(mycfg_ Workspace) !Workspace {
return mycfg_
// obj_init validates and initializes the HeroPrompt instance
fn obj_init(mycfg_ HeroPrompt) !HeroPrompt {
mut mycfg := mycfg_
// Initialize workspaces map if nil
if mycfg.workspaces.len == 0 {
mycfg.workspaces = map[string]&Workspace{}
}
// Set ID if not set
if mycfg.id.len == 0 {
mycfg.id = rand.uuid_v4()
}
// Set timestamps if not set
if mycfg.created.unixt == 0 {
mycfg.created = ourtime.now()
}
if mycfg.updated.unixt == 0 {
mycfg.updated = ourtime.now()
}
// Set default log path if not set
if mycfg.log_path.len == 0 {
mycfg.log_path = '/tmp/heroprompt_logs'
}
// Initialize logger instance (create directory if needed)
mycfg.logger = logger.new(path: mycfg.log_path, console_output: false) or {
// If logger creation fails, create a dummy logger to avoid nil reference
// This can happen if path is invalid, but we'll handle it gracefully
logger.new(path: '/tmp/heroprompt_logs', console_output: false) or {
panic('Failed to initialize logger: ${err}')
}
}
// Restore parent references for all workspaces
// This is critical because parent references are not serialized to Redis
for _, mut ws in mycfg.workspaces {
ws.parent = &mycfg
}
return mycfg
}
/////////////NORMALLY NO NEED TO TOUCH
pub fn heroscript_loads(heroscript string) !Workspace {
mut pb := playbook.new(text: heroscript)!
// Accept either define or configure; prefer define if present
mut action_name := 'heroprompt.define'
if !pb.exists_once(filter: action_name) {
action_name = 'heroprompt.configure'
if !pb.exists_once(filter: action_name) {
return error("heroprompt: missing 'heroprompt.define' or 'heroprompt.configure' action")
}
}
mut action := pb.get(filter: action_name)!
mut p := action.params
return Workspace{
name: p.get_default('name', 'default')!
created: time.now()
updated: time.now()
children: []HeropromptChild{}
}
pub fn heroscript_loads(heroscript string) !HeroPrompt {
mut obj := encoderhero.decode[HeroPrompt](heroscript)!
return obj
}

View File

@@ -0,0 +1,330 @@
module heroprompt
import freeflowuniverse.herolib.develop.codewalker
import os
// Prompt generation functionality for HeroPrompt workspaces
// HeropromptTmpPrompt is the template struct for prompt generation
struct HeropromptTmpPrompt {
pub mut:
user_instructions string
file_map string
file_contents string
}
@[params]
pub struct GenerateFileMapParams {
pub mut:
selected_files []string // List of file paths to mark as selected (optional, if empty all files are selected)
show_all bool // If true, show all files in directories; if false, show only selected files
}
// generate_file_map generates a hierarchical tree structure of the workspace
// with selected files marked with '*'
pub fn (ws &Workspace) generate_file_map(args GenerateFileMapParams) !string {
mut all_files := []string{}
mut selected_set := map[string]bool{}
// Build set of selected files for quick lookup
for path in args.selected_files {
selected_set[path] = true
}
// Collect all files from directories
for _, dir in ws.directories {
content := dir.get_contents() or { continue }
for file in content.files {
all_files << file.path
}
}
// Add standalone files
for file in ws.files {
all_files << file.path
}
// If no specific files selected, select all
mut files_to_show := if args.selected_files.len > 0 {
args.selected_files.clone()
} else {
all_files.clone()
}
// Find common base path
mut base_path := ''
if files_to_show.len > 0 {
base_path = find_common_base_path(files_to_show)
}
// Generate tree using codewalker
mut tree := ''
if args.show_all {
// Show full directory tree with selected files marked
tree = generate_full_tree_with_selection(files_to_show, all_files, base_path)
} else {
// Show minimal tree with only selected files
tree = codewalker.build_file_tree_selected(files_to_show, base_path)
}
// Add config note
mut output := 'Config: directory-only view; selected files shown.\n\n'
// Add base path if available
if base_path.len > 0 {
output += '${os.base(base_path)}\n'
}
output += tree
return output
}
// find_common_base_path finds the common base directory for a list of file paths
fn find_common_base_path(paths []string) string {
if paths.len == 0 {
return ''
}
if paths.len == 1 {
return os.dir(paths[0])
}
// Split all paths into components
mut path_parts := [][]string{}
for path in paths {
parts := path.split(os.path_separator)
path_parts << parts
}
// Find common prefix
mut common := []string{}
// Find minimum length
mut min_len := path_parts[0].len
for parts in path_parts {
if parts.len < min_len {
min_len = parts.len
}
}
for i in 0 .. min_len - 1 { // -1 to exclude filename
part := path_parts[0][i]
mut all_match := true
for j in 1 .. path_parts.len {
if path_parts[j][i] != part {
all_match = false
break
}
}
if all_match {
common << part
} else {
break
}
}
if common.len == 0 {
return ''
}
return common.join(os.path_separator)
}
// generate_full_tree_with_selection generates a full directory tree with selected files marked
fn generate_full_tree_with_selection(selected_files []string, all_files []string, base_path string) string {
// For now, use the minimal tree approach
// TODO: Implement full tree with selective marking
return codewalker.build_file_tree_selected(selected_files, base_path)
}
@[params]
pub struct GenerateFileContentsParams {
pub mut:
selected_files []string // List of file paths to include (optional, if empty all files are included)
include_path bool = true // Include file path as header
}
// generate_file_contents generates formatted file contents section
pub fn (ws &Workspace) generate_file_contents(args GenerateFileContentsParams) !string {
mut output := ''
mut files_to_include := map[string]HeropromptFile{}
// Collect all files from directories
for _, dir in ws.directories {
content := dir.get_contents() or { continue }
for file in content.files {
// Normalize path for consistent comparison
normalized_path := os.real_path(file.path)
// Create a mutable copy to update selection state
mut file_copy := file
// Check if file is selected in directory's selection map
if normalized_path in dir.selected_files {
file_copy.is_selected = dir.selected_files[normalized_path]
}
files_to_include[normalized_path] = file_copy
}
}
// Add standalone files
for file in ws.files {
// Normalize path for consistent comparison
normalized_path := os.real_path(file.path)
files_to_include[normalized_path] = file
}
// Filter by selected files if specified
mut files_to_output := []HeropromptFile{}
if args.selected_files.len > 0 {
for path in args.selected_files {
// Normalize the selected path for comparison
normalized_path := os.real_path(path)
if normalized_path in files_to_include {
files_to_output << files_to_include[normalized_path]
}
}
} else {
for _, file in files_to_include {
files_to_output << file
}
}
// Sort files by path for consistent output
files_to_output.sort(a.path < b.path)
// Generate content for each file
for file in files_to_output {
if args.include_path {
output += 'File: ${file.path}\n'
}
// Determine language for syntax highlighting
ext := file.extension()
lang := if ext.len > 0 { ext } else { 'text' }
output += '```${lang}\n'
output += file.content
if !file.content.ends_with('\n') {
output += '\n'
}
output += '```\n\n'
}
return output
}
@[params]
pub struct GeneratePromptParams {
pub mut:
instruction string // User's instruction/question
selected_files []string // List of file paths to include (optional, if empty all files are included)
show_all_files bool // If true, show all files in file_map; if false, show only selected
}
// generate_prompt generates a complete AI prompt combining file_map, file_contents, and user instructions
// If selected_files is empty, automatically uses files marked as selected in the workspace
pub fn (ws &Workspace) generate_prompt(args GeneratePromptParams) !string {
// Determine which files to include
mut files_to_include := args.selected_files.clone()
// If no files specified, use selected files from workspace
if files_to_include.len == 0 {
files_to_include = ws.get_selected_files() or { []string{} }
// If still no files, return error with helpful message
if files_to_include.len == 0 {
return error('no files selected for prompt generation. Use select_file() to select files or provide selected_files parameter')
}
}
// Generate file map
file_map := ws.generate_file_map(
selected_files: files_to_include
show_all: args.show_all_files
)!
// Generate file contents
file_contents := ws.generate_file_contents(
selected_files: files_to_include
include_path: true
)!
// Build user instructions
mut user_instructions := args.instruction
if user_instructions.len > 0 && !user_instructions.ends_with('\n') {
user_instructions += '\n'
}
// Use template to generate prompt
prompt := HeropromptTmpPrompt{
user_instructions: user_instructions
file_map: file_map
file_contents: file_contents
}
result := $tmpl('./templates/prompt.template')
return result
}
// generate_prompt_simple is a convenience method that generates a prompt with just an instruction
// and includes all files from the workspace
pub fn (ws &Workspace) generate_prompt_simple(instruction string) !string {
return ws.generate_prompt(
instruction: instruction
selected_files: []
show_all_files: false
)
}
// get_all_file_paths returns all file paths in the workspace
pub fn (ws &Workspace) get_all_file_paths() ![]string {
mut paths := []string{}
// Collect from directories
for _, dir in ws.directories {
content := dir.get_contents() or { continue }
for file in content.files {
paths << file.path
}
}
// Add standalone files
for file in ws.files {
paths << file.path
}
return paths
}
// filter_files_by_extension filters file paths by extension
pub fn (ws &Workspace) filter_files_by_extension(extensions []string) ![]string {
all_paths := ws.get_all_file_paths()!
mut filtered := []string{}
for path in all_paths {
ext := os.file_ext(path).trim_left('.')
if ext in extensions {
filtered << path
}
}
return filtered
}
// filter_files_by_pattern filters file paths by pattern (simple substring match)
pub fn (ws &Workspace) filter_files_by_pattern(pattern string) ![]string {
all_paths := ws.get_all_file_paths()!
mut filtered := []string{}
pattern_lower := pattern.to_lower()
for path in all_paths {
if path.to_lower().contains(pattern_lower) {
filtered << path
}
}
return filtered
}

View File

@@ -0,0 +1,192 @@
module heroprompt
import freeflowuniverse.herolib.core.base
// Test HeroPrompt: new_workspace
fn test_heroprompt_new_workspace() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_new_ws_hp') or {}
defer {
delete(name: 'test_new_ws_hp') or {}
}
// Create heroprompt instance
mut hp := get(name: 'test_new_ws_hp', create: true)!
hp.run_in_tests = true
// Create workspace
ws := hp.new_workspace(name: 'test_workspace', description: 'Test workspace')!
assert ws.name == 'test_workspace'
assert ws.description == 'Test workspace'
assert ws.id.len == 36 // UUID length
assert hp.workspaces.len == 1
}
// Test HeroPrompt: get_workspace
fn test_heroprompt_get_workspace() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_get_ws_hp') or {}
defer {
delete(name: 'test_get_ws_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_get_ws_hp', create: true)!
hp.run_in_tests = true
hp.new_workspace(name: 'test_workspace')!
// Get workspace
ws := hp.get_workspace('test_workspace')!
assert ws.name == 'test_workspace'
// Try to get non-existent workspace
hp.get_workspace('nonexistent') or {
assert err.msg().contains('workspace not found')
return
}
assert false, 'Expected error when getting non-existent workspace'
}
// Test HeroPrompt: list_workspaces
fn test_heroprompt_list_workspaces() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_list_ws_hp') or {}
defer {
delete(name: 'test_list_ws_hp') or {}
}
// Create heroprompt instance
mut hp := get(name: 'test_list_ws_hp', create: true)!
hp.run_in_tests = true
// Initially empty
assert hp.list_workspaces().len == 0
// Create workspaces
hp.new_workspace(name: 'workspace1')!
hp.new_workspace(name: 'workspace2')!
hp.new_workspace(name: 'workspace3')!
// List workspaces
workspaces := hp.list_workspaces()
assert workspaces.len == 3
}
// Test HeroPrompt: delete_workspace
fn test_heroprompt_delete_workspace() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_del_ws_hp') or {}
defer {
delete(name: 'test_del_ws_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_del_ws_hp', create: true)!
hp.run_in_tests = true
hp.new_workspace(name: 'test_workspace')!
assert hp.workspaces.len == 1
// Delete workspace
hp.delete_workspace('test_workspace')!
assert hp.workspaces.len == 0
// Try to delete non-existent workspace
hp.delete_workspace('nonexistent') or {
assert err.msg().contains('workspace not found')
return
}
assert false, 'Expected error when deleting non-existent workspace'
}
// Test HeroPrompt: duplicate workspace
fn test_heroprompt_duplicate_workspace() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_dup_ws_hp') or {}
defer {
delete(name: 'test_dup_ws_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_dup_ws_hp', create: true)!
hp.run_in_tests = true
hp.new_workspace(name: 'test_workspace')!
// Try to create duplicate workspace
hp.new_workspace(name: 'test_workspace') or {
assert err.msg().contains('workspace already exists')
return
}
assert false, 'Expected error when creating duplicate workspace'
}
// Test HeroPrompt: auto-save after workspace operations
fn test_heroprompt_auto_save() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_autosave_hp') or {}
defer {
delete(name: 'test_autosave_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_autosave_hp', create: true)!
hp.run_in_tests = true
mut ws := hp.new_workspace(name: 'test_workspace')!
// Get fresh instance from Redis to verify save
hp2 := get(name: 'test_autosave_hp', fromdb: true)!
assert hp2.workspaces.len == 1
assert 'test_workspace' in hp2.workspaces
}
// Test HeroPrompt: logging suppression in tests
fn test_heroprompt_logging_suppression() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_logging_hp') or {}
defer {
delete(name: 'test_logging_hp') or {}
}
// Create heroprompt instance
mut hp := get(name: 'test_logging_hp', create: true)!
// Test with logging enabled (should not crash)
hp.run_in_tests = false
hp.log(.info, 'Test log message')
// Test with logging disabled
hp.run_in_tests = true
hp.log(.info, 'This should be suppressed')
// If we get here without crashing, logging works
assert true
}

781
lib/develop/heroprompt/heroprompt_workspace.v Executable file → Normal file
View File

@@ -1,547 +1,304 @@
module heroprompt
import rand
import time
import os
import freeflowuniverse.herolib.core.pathlib
import freeflowuniverse.herolib.develop.codewalker
import freeflowuniverse.herolib.ui.console
import freeflowuniverse.herolib.data.ourtime
// Workspace Methods - Directory and File Management
// Selection API
@[params]
pub struct AddDirParams {
pub struct WorkspaceAddDirectoryParams {
pub mut:
path string @[required]
include_tree bool = true // true for base directories, false for selected directories
path string @[required] // Path to directory directory
name string // Optional custom name (defaults to directory name)
description string // Optional description
scan bool = true // Whether to scan the directory (default: true)
}
// add_directory adds a new directory to this workspace
pub fn (mut ws Workspace) add_directory(args WorkspaceAddDirectoryParams) !&Directory {
console.print_header('Adding directory to workspace: ${ws.name}')
// Create directory
repo := new_directory(
path: args.path
name: args.name
description: args.description
)!
// Check if directory already exists
for _, existing_repo in ws.directories {
if existing_repo.path == repo.path {
return error('directory already added: ${repo.path}')
}
}
// Add to workspace
ws.directories[repo.id] = &repo
ws.updated = ourtime.now()
// Auto-save to Redis
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after adding directory: ${err}')
}
console.print_info('Directory added: ${repo.name}')
return ws.directories[repo.id] or { return error('failed to retrieve added directory') }
}
@[params]
pub struct AddFileParams {
pub struct WorkspaceRemoveDirectoryParams {
pub mut:
path string @[required]
id string // Directory ID
path string // Directory path (alternative to ID)
name string // Directory name (alternative to ID)
}
// add a directory to the selection (no recursion stored; recursion is done on-demand)
pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild {
if args.path.len == 0 {
return error('the directory path is required')
}
// remove_directory removes a directory from this workspace
pub fn (mut ws Workspace) remove_directory(args WorkspaceRemoveDirectoryParams) ! {
mut found_id := ''
mut dir_path := pathlib.get(args.path)
if !dir_path.exists() || !dir_path.is_dir() {
return error('path is not an existing directory: ${args.path}')
}
abs_path := dir_path.realpath()
name := dir_path.name()
for child in wsp.children {
if child.path.cat == .dir && child.path.path == abs_path {
return error('the directory is already added to the workspace')
// Find directory by ID, path, or name
if args.id.len > 0 {
if args.id in ws.directories {
found_id = args.id
}
}
mut ch := HeropromptChild{
path: pathlib.Path{
path: abs_path
cat: .dir
exist: .yes
}
name: name
include_tree: args.include_tree
}
wsp.children << ch
wsp.save()!
return ch
}
// add a file to the selection
pub fn (mut wsp Workspace) add_file(args AddFileParams) !HeropromptChild {
if args.path.len == 0 {
return error('The file path is required')
}
mut file_path := pathlib.get(args.path)
if !file_path.exists() || !file_path.is_file() {
return error('Path is not an existing file: ${args.path}')
}
abs_path := file_path.realpath()
name := file_path.name()
for child in wsp.children {
if child.path.cat == .file && child.name == name {
return error('another file with the same name already exists: ${name}')
}
if child.path.cat == .dir && child.name == name {
return error('${name}: is a directory, cannot add file with same name')
}
}
content := file_path.read() or { '' }
mut ch := HeropromptChild{
path: pathlib.Path{
path: abs_path
cat: .file
exist: .yes
}
name: name
content: content
}
wsp.children << ch
wsp.save()!
return ch
}
// Removal API
@[params]
pub struct RemoveParams {
pub mut:
path string
name string
}
// Remove a directory from the selection (by absolute path or name)
pub fn (mut wsp Workspace) remove_dir(args RemoveParams) ! {
if args.path.len == 0 && args.name.len == 0 {
return error('either path or name is required to remove a directory')
}
mut idxs := []int{}
for i, ch in wsp.children {
if ch.path.cat != .dir {
continue
}
if args.path.len > 0 && pathlib.get(args.path).realpath() == ch.path.path {
idxs << i
continue
}
if args.name.len > 0 && args.name == ch.name {
idxs << i
}
}
if idxs.len == 0 {
return error('no matching directory found to remove')
}
// remove from end to start to keep indices valid
idxs.sort(a > b)
for i in idxs {
wsp.children.delete(i)
}
wsp.save()!
}
// Remove a file from the selection (by absolute path or name)
pub fn (mut wsp Workspace) remove_file(args RemoveParams) ! {
if args.path.len == 0 && args.name.len == 0 {
return error('either path or name is required to remove a file')
}
mut idxs := []int{}
for i, ch in wsp.children {
if ch.path.cat != .file {
continue
}
if args.path.len > 0 && pathlib.get(args.path).realpath() == ch.path.path {
idxs << i
continue
}
if args.name.len > 0 && args.name == ch.name {
idxs << i
}
}
if idxs.len == 0 {
return error('no matching file found to remove')
}
idxs.sort(a > b)
for i in idxs {
wsp.children.delete(i)
}
wsp.save()!
}
// Delete this workspace from the store
pub fn (wsp &Workspace) delete_workspace() ! {
delete(name: wsp.name)!
}
// Update this workspace (name and/or base_path)
@[params]
pub struct UpdateParams {
pub mut:
name string
base_path string
}
pub fn (wsp &Workspace) update_workspace(args UpdateParams) !&Workspace {
mut updated := Workspace{
name: if args.name.len > 0 { args.name } else { wsp.name }
children: wsp.children
created: wsp.created
updated: time.now()
is_saved: true
}
// if name changed, delete old key first
if updated.name != wsp.name {
delete(name: wsp.name)!
}
set(updated)!
return get(name: updated.name)!
}
// @[params]
// pub struct UpdateParams {
// pub mut:
// name string
// base_path string
// // Update only the name and the base path for now
// }
// // Delete this workspace from the store
// pub fn (wsp &Workspace) update_workspace(args_ UpdateParams) ! {
// delete(name: wsp.name)!
// }
// List workspaces (wrapper over factory list)
pub fn list_workspaces() ![]&Workspace {
return list(fromdb: false)!
}
pub fn list_workspaces_fromdb() ![]&Workspace {
return list(fromdb: true)!
}
// List entries in a directory relative to this workspace base or absolute
@[params]
pub struct ListArgs {
pub mut:
path string // if empty, will use workspace.base_path
}
pub struct ListItem {
pub:
name string
typ string @[json: 'type']
}
pub fn (wsp &Workspace) list_dir(base_path string, rel_path string) ![]ListItem {
// Create an ignore matcher with default patterns
ignore_matcher := codewalker.gitignore_matcher_new()
items := codewalker.list_directory_filtered(base_path, rel_path, &ignore_matcher)!
mut out := []ListItem{}
for item in items {
out << ListItem{
name: item.name
typ: item.typ
}
}
return out
}
// Get the currently selected children (copy)
pub fn (wsp Workspace) selected_children() []HeropromptChild {
return wsp.children.clone()
}
// build_file_content generates formatted content for all selected files (and all files under selected dirs)
fn (wsp Workspace) build_file_content() !string {
mut content := ''
// files selected directly
for ch in wsp.children {
if ch.path.cat == .file {
if content.len > 0 {
content += '\n\n'
}
content += '${ch.path.path}\n'
ext := get_file_extension(ch.name)
if ch.content.len == 0 {
// read on demand using pathlib
mut file_path := pathlib.get(ch.path.path)
ch_content := file_path.read() or { '' }
if ch_content.len == 0 {
content += '(Empty file)\n'
} else {
content += '```' + ext + '\n' + ch_content + '\n```'
}
} else {
content += '```' + ext + '\n' + ch.content + '\n```'
} else if args.path.len > 0 {
// Normalize the path for comparison
normalized_path := os.real_path(args.path)
for id, repo in ws.directories {
if repo.path == normalized_path {
found_id = id
break
}
}
}
return content
}
// build_file_content_for_paths generates formatted content for specific selected paths
fn (wsp Workspace) build_file_content_for_paths(selected_paths []string) !string {
mut content := ''
for path in selected_paths {
if !os.exists(path) {
continue // Skip non-existent paths
}
if content.len > 0 {
content += '\n\n'
}
if os.is_file(path) {
// Add file content
content += '${path}\n'
file_content := os.read_file(path) or {
content += '(Error reading file: ${err.msg()})\n'
continue
}
ext := get_file_extension(os.base(path))
if file_content.len == 0 {
content += '(Empty file)\n'
} else {
content += '```' + ext + '\n' + file_content + '\n```'
}
} else if os.is_dir(path) {
// Add directory content using codewalker
mut cw := codewalker.new(codewalker.CodeWalkerArgs{})!
mut fm := cw.filemap_get(path: path)!
for filepath, filecontent in fm.content {
if content.len > 0 {
content += '\n\n'
}
content += '${path}/${filepath}\n'
ext := get_file_extension(filepath)
if filecontent.len == 0 {
content += '(Empty file)\n'
} else {
content += '```' + ext + '\n' + filecontent + '\n```'
}
}
}
}
return content
}
pub struct HeropromptTmpPrompt {
pub mut:
user_instructions string
file_map string
file_contents string
}
fn (wsp Workspace) build_user_instructions(text string) string {
return text
}
// build_file_map creates a unified tree showing the minimal path structure for all workspace items
pub fn (wsp Workspace) build_file_map() string {
// Collect all paths from workspace children
mut all_paths := []string{}
for ch in wsp.children {
all_paths << ch.path.path
}
if all_paths.len == 0 {
return ''
}
// Expand directories to include all their contents
expanded_paths := expand_directory_paths(all_paths)
// Find common root path to make the tree relative
common_root := find_common_root_path(expanded_paths)
// Build unified tree using the selected tree function
return codewalker.build_selected_tree(expanded_paths, common_root)
}
// find_common_root_path finds the common root directory for a list of paths
fn find_common_root_path(paths []string) string {
if paths.len == 0 {
return ''
}
if paths.len == 1 {
// For single path, use its parent directory as root
return os.dir(paths[0])
}
// Split all paths into components
mut path_components := [][]string{}
for path in paths {
// Normalize path and split into components
normalized := os.real_path(path)
components := normalized.split(os.path_separator).filter(it.len > 0)
path_components << components
}
// Find common prefix
mut common_components := []string{}
if path_components.len > 0 {
// Find minimum length manually
mut min_len := path_components[0].len
for components in path_components {
if components.len < min_len {
min_len = components.len
}
}
for i in 0 .. min_len {
component := path_components[0][i]
mut all_match := true
for j in 1 .. path_components.len {
if path_components[j][i] != component {
all_match = false
break
}
}
if all_match {
common_components << component
} else {
} else if args.name.len > 0 {
for id, repo in ws.directories {
if repo.name == args.name {
found_id = id
break
}
}
}
// Build common root path
if common_components.len == 0 {
return os.path_separator
if found_id.len == 0 {
return error('no matching directory found')
}
return os.path_separator + common_components.join(os.path_separator)
ws.directories.delete(found_id)
ws.updated = ourtime.now()
// Auto-save to Redis
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after removing directory: ${err}')
}
console.print_info('Directory removed from workspace')
}
// expand_directory_paths expands directory paths to include all files and subdirectories
fn expand_directory_paths(paths []string) []string {
mut expanded := []string{}
// get_directory retrieves a directory by ID
pub fn (ws &Workspace) get_directory(id string) !&Directory {
if id !in ws.directories {
return error('directory not found: ${id}')
}
return ws.directories[id] or { return error('directory not found: ${id}') }
}
for path in paths {
if !os.exists(path) {
continue
// list_directories returns all directories in this workspace
pub fn (ws &Workspace) list_directories() []&Directory {
mut repos := []&Directory{}
for _, repo in ws.directories {
repos << repo
}
return repos
}
@[params]
pub struct WorkspaceAddFileParams {
pub mut:
path string @[required] // Path to file
}
// add_file adds a standalone file to this workspace
pub fn (mut ws Workspace) add_file(args WorkspaceAddFileParams) !HeropromptFile {
console.print_info('Adding file to workspace: ${args.path}')
// Create file using the file factory
file := new_file(path: args.path)!
// Check if file already exists
for existing_file in ws.files {
if existing_file.path == file.path {
return error('file already added: ${file.path}')
}
}
if os.is_file(path) {
// Add files directly
expanded << path
} else if os.is_dir(path) {
// Expand directories using codewalker to get all files
mut cw := codewalker.new(codewalker.CodeWalkerArgs{}) or { continue }
mut fm := cw.filemap_get(path: path) or { continue }
// Add to workspace
ws.files << file
ws.updated = ourtime.now()
// Add the directory itself
expanded << path
// Auto-save to Redis
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after adding file: ${err}')
}
// Add all files in the directory
for filepath, _ in fm.content {
full_path := os.join_path(path, filepath)
expanded << full_path
console.print_info('File added: ${file.name}')
return file
}
@[params]
pub struct WorkspaceRemoveFileParams {
pub mut:
id string // File ID
path string // File path (alternative to ID)
name string // File name (alternative to ID)
}
// remove_file removes a file from this workspace
pub fn (mut ws Workspace) remove_file(args WorkspaceRemoveFileParams) ! {
mut found_idx := -1
// Find file by ID, path, or name
for idx, file in ws.files {
if (args.id.len > 0 && file.id == args.id)
|| (args.path.len > 0 && file.path == args.path)
|| (args.name.len > 0 && file.name == args.name) {
found_idx = idx
break
}
}
if found_idx == -1 {
return error('no matching file found')
}
ws.files.delete(found_idx)
ws.updated = ourtime.now()
// Auto-save to Redis
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after removing file: ${err}')
}
console.print_info('File removed from workspace')
}
// get_file retrieves a file by ID
pub fn (ws &Workspace) get_file(id string) !HeropromptFile {
for file in ws.files {
if file.id == id {
return file
}
}
return error('file not found: ${id}')
}
// list_files returns all standalone files in this workspace
pub fn (ws &Workspace) list_files() []HeropromptFile {
return ws.files
}
// item_count returns total items
pub fn (ws &Workspace) item_count() int {
return ws.directories.len + ws.files.len
}
// str returns a string representation of the workspace
pub fn (ws &Workspace) str() string {
return 'Workspace{name: "${ws.name}", directories: ${ws.directories.len}, files: ${ws.files.len}, is_active: ${ws.is_active}}'
}
// File Selection Methods
// select_file marks a file as selected for prompt generation
// The file path should be absolute or will be resolved relative to workspace
pub fn (mut ws Workspace) select_file(path string) ! {
// Normalize path
file_path := os.real_path(path)
// Try to find and select in standalone files
for mut file in ws.files {
if os.real_path(file.path) == file_path {
file.is_selected = true
ws.updated = ourtime.now()
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after selecting file: ${err}')
}
return
}
}
// File not found in workspace
return error('file not found in workspace: ${path}')
}
// deselect_file marks a file as not selected
pub fn (mut ws Workspace) deselect_file(path string) ! {
// Normalize path
file_path := os.real_path(path)
// Try to find and deselect in standalone files
for mut file in ws.files {
if os.real_path(file.path) == file_path {
file.is_selected = false
ws.updated = ourtime.now()
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after deselecting file: ${err}')
}
return
}
}
// File not found in workspace
return error('file not found in workspace: ${path}')
}
// select_all_files marks all files in the workspace as selected
pub fn (mut ws Workspace) select_all_files() ! {
// Select all standalone files
for mut file in ws.files {
file.is_selected = true
}
ws.updated = ourtime.now()
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after selecting all files: ${err}')
}
}
// deselect_all_files marks all files in the workspace as not selected
pub fn (mut ws Workspace) deselect_all_files() ! {
// Deselect all standalone files
for mut file in ws.files {
file.is_selected = false
}
ws.updated = ourtime.now()
ws.parent.save() or {
console.print_stderr('Warning: Failed to auto-save after deselecting all files: ${err}')
}
}
// get_selected_files returns all files that are currently selected
pub fn (ws &Workspace) get_selected_files() ![]string {
mut selected := []string{}
// Collect selected standalone files
for file in ws.files {
if file.is_selected {
selected << file.path
}
}
// Collect selected files from directories
for _, dir in ws.directories {
// Add files from directory's selected_files map
for file_path, is_selected in dir.selected_files {
if is_selected {
selected << file_path
}
}
}
return expanded
}
pub struct WorkspacePrompt {
pub mut:
text string
}
pub fn (wsp Workspace) prompt(args WorkspacePrompt) string {
user_instructions := wsp.build_user_instructions(args.text)
file_map := wsp.build_file_map()
file_contents := wsp.build_file_content() or { '(Error building file contents)' }
prompt := HeropromptTmpPrompt{
user_instructions: user_instructions
file_map: file_map
file_contents: file_contents
}
reprompt := $tmpl('./templates/prompt.template')
return reprompt
}
@[params]
pub struct WorkspacePromptWithSelection {
pub mut:
text string
selected_paths []string
}
// Generate prompt with specific selected paths instead of using workspace children
pub fn (wsp Workspace) prompt_with_selection(args WorkspacePromptWithSelection) !string {
user_instructions := wsp.build_user_instructions(args.text)
// Build file map for selected paths (unified tree)
file_map := if args.selected_paths.len > 0 {
// Expand directories to include all their contents
expanded_paths := expand_directory_paths(args.selected_paths)
common_root := find_common_root_path(expanded_paths)
codewalker.build_selected_tree(expanded_paths, common_root)
} else {
// Fallback to workspace file map if no selections
wsp.build_file_map()
}
// Build file content only for selected paths
file_contents := wsp.build_file_content_for_paths(args.selected_paths) or {
return error('failed to build file content: ${err.msg()}')
}
prompt := HeropromptTmpPrompt{
user_instructions: user_instructions
file_map: file_map
file_contents: file_contents
}
reprompt := $tmpl('./templates/prompt.template')
return reprompt
}
// Save the workspace
fn (mut wsp Workspace) save() !&Workspace {
wsp.updated = time.now()
wsp.is_saved = true
set(wsp)!
return get(name: wsp.name)!
}
// Generate a random name for the workspace
pub fn generate_random_workspace_name() string {
adjectives := [
'brave',
'bright',
'clever',
'swift',
'noble',
'mighty',
'fearless',
'bold',
'wise',
'epic',
'valiant',
'fierce',
'legendary',
'heroic',
'dynamic',
]
nouns := [
'forge',
'script',
'ocean',
'phoenix',
'atlas',
'quest',
'shield',
'dragon',
'code',
'summit',
'path',
'realm',
'spark',
'anvil',
'saga',
]
// Seed randomness with time
rand.seed([u32(time.now().unix()), u32(time.now().nanosecond)])
adj := adjectives[rand.intn(adjectives.len) or { 0 }]
noun := nouns[rand.intn(nouns.len) or { 0 }]
number := rand.intn(100) or { 0 } // 099
return '${adj}_${noun}_${number}'
return selected
}

View File

@@ -1,121 +1,302 @@
module heroprompt
import os
import freeflowuniverse.herolib.core.base
fn test_multiple_dirs_same_name() ! {
// Create two temporary folders with the same basename "proj"
// Test workspace: add directory
fn test_workspace_add_directory() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_add_repo_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
dir1 := os.join_path(temp_base, 'test_heroprompt_1', 'proj')
dir2 := os.join_path(temp_base, 'test_heroprompt_2', 'proj')
// Ensure directories exist
os.mkdir_all(dir1)!
os.mkdir_all(dir2)!
// Create test files in each directory
os.write_file(os.join_path(dir1, 'file1.txt'), 'content1')!
os.write_file(os.join_path(dir2, 'file2.txt'), 'content2')!
test_dir := os.join_path(temp_base, 'test_heroprompt_add_repo')
os.mkdir_all(test_dir)!
os.write_file(os.join_path(test_dir, 'test.txt'), 'test content')!
defer {
// Cleanup
os.rmdir_all(os.join_path(temp_base, 'test_heroprompt_1')) or {}
os.rmdir_all(os.join_path(temp_base, 'test_heroprompt_2')) or {}
os.rmdir_all(test_dir) or {}
delete(name: 'test_add_repo_hp') or {}
}
mut ws := Workspace{
name: 'testws'
children: []HeropromptChild{}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_add_repo_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
repo := ws.add_directory(path: test_dir, name: 'Test Repo')!
assert ws.directories.len == 1
assert repo.name == 'Test Repo'
assert repo.path == os.real_path(test_dir)
assert repo.id.len > 0
}
// Test workspace: add directory without custom name
fn test_workspace_add_directory_auto_name() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_auto_name_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'my_custom_dir_name')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_auto_name_hp') or {}
}
// First dir should succeed
child1 := ws.add_dir(path: dir1)!
assert ws.children.len == 1
assert child1.name == 'proj'
assert child1.path.path == os.real_path(dir1)
// Create heroprompt instance and workspace
mut hp := get(name: 'test_auto_name_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Second dir same basename, different absolute path should also succeed
child2 := ws.add_dir(path: dir2)!
assert ws.children.len == 2
assert child2.name == 'proj'
assert child2.path.path == os.real_path(dir2)
// Add directory without custom name
repo := ws.add_directory(path: test_dir)!
// Verify both children have different absolute paths
assert child1.path.path != child2.path.path
// Name should be extracted from directory name
assert repo.name == 'my_custom_dir_name'
}
// Try to add the same directory again should fail
ws.add_dir(path: dir1) or {
assert err.msg().contains('already added to the workspace')
// Test workspace: add duplicate directory
fn test_workspace_add_duplicate_directory() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_dup_repo_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_dup_repo')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_dup_repo_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_dup_repo_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
ws.add_directory(path: test_dir)!
// Try to add same directory again
ws.add_directory(path: test_dir) or {
assert err.msg().contains('already added')
return
}
assert false, 'Expected error when adding same directory twice'
assert false, 'Expected error when adding duplicate directory'
}
fn test_build_file_map_multiple_roots() ! {
// Create temporary directories
// Test workspace: remove directory by ID
fn test_workspace_remove_directory_by_id() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_remove_repo_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
dir1 := os.join_path(temp_base, 'test_map_1', 'src')
dir2 := os.join_path(temp_base, 'test_map_2', 'src')
os.mkdir_all(dir1)!
os.mkdir_all(dir2)!
// Create test files
os.write_file(os.join_path(dir1, 'main.v'), 'fn main() { println("hello from dir1") }')!
os.write_file(os.join_path(dir2, 'app.v'), 'fn app() { println("hello from dir2") }')!
defer {
os.rmdir_all(os.join_path(temp_base, 'test_map_1')) or {}
os.rmdir_all(os.join_path(temp_base, 'test_map_2')) or {}
}
mut ws := Workspace{
name: 'testws_map'
children: []HeropromptChild{}
}
// Add both directories
ws.add_dir(path: dir1)!
ws.add_dir(path: dir2)!
// Build file map
file_map := ws.build_file_map()
// Should contain both directory paths in the parent_path
assert file_map.contains(os.real_path(dir1))
assert file_map.contains(os.real_path(dir2))
// Should show correct file count (2 files total)
assert file_map.contains('Selected Files: 2')
// Should contain both file extensions
assert file_map.contains('v(2)')
}
fn test_single_dir_backward_compatibility() ! {
// Test that single directory workspaces still work as before
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_single', 'myproject')
test_dir := os.join_path(temp_base, 'test_heroprompt_remove_repo')
os.mkdir_all(test_dir)!
os.write_file(os.join_path(test_dir, 'main.v'), 'fn main() { println("single dir test") }')!
defer {
os.rmdir_all(os.join_path(temp_base, 'test_single')) or {}
os.rmdir_all(test_dir) or {}
delete(name: 'test_remove_repo_hp') or {}
}
mut ws := Workspace{
name: 'testws_single'
children: []HeropromptChild{}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_remove_repo_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add single directory
child := ws.add_dir(path: test_dir)!
assert ws.children.len == 1
assert child.name == 'myproject'
// Add directory
repo := ws.add_directory(path: test_dir)!
assert ws.directories.len == 1
// Build file map - should work as before for single directory
file_map := ws.build_file_map()
assert file_map.contains('Selected Files: 1')
// Just check that the file map is not empty and contains some content
assert file_map.len > 0
// Remove by ID
ws.remove_directory(id: repo.id)!
assert ws.directories.len == 0
}
// Test workspace: remove directory by path
fn test_workspace_remove_directory_by_path() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_remove_path_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_remove_path')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_remove_path_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_remove_path_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
ws.add_directory(path: test_dir)!
assert ws.directories.len == 1
// Remove by path
ws.remove_directory(path: test_dir)!
assert ws.directories.len == 0
}
// Test workspace: get directory
fn test_workspace_get_directory() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_get_repo_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_get_repo')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_get_repo_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_get_repo_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
repo := ws.add_directory(path: test_dir)!
// Get directory
retrieved_repo := ws.get_directory(repo.id)!
assert retrieved_repo.id == repo.id
assert retrieved_repo.path == repo.path
}
// Test workspace: list directories
fn test_workspace_list_directories() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_list_repos_hp') or {}
// Create temp directories
temp_base := os.temp_dir()
test_dir1 := os.join_path(temp_base, 'test_heroprompt_list_1')
test_dir2 := os.join_path(temp_base, 'test_heroprompt_list_2')
os.mkdir_all(test_dir1)!
os.mkdir_all(test_dir2)!
defer {
os.rmdir_all(test_dir1) or {}
os.rmdir_all(test_dir2) or {}
delete(name: 'test_list_repos_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_list_repos_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directories
ws.add_directory(path: test_dir1)!
ws.add_directory(path: test_dir2)!
// List directories
repos := ws.list_directories()
assert repos.len == 2
}
// Test workspace: add file
fn test_workspace_add_file() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_add_file_hp') or {}
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_add_file.txt')
os.write_file(test_file, 'test file content')!
defer {
os.rm(test_file) or {}
delete(name: 'test_add_file_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_add_file_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add file
file := ws.add_file(path: test_file)!
assert ws.files.len == 1
assert file.content == 'test file content'
assert file.path == os.real_path(test_file)
}
// Test workspace: item count
fn test_workspace_item_count() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_count_hp') or {}
// Create temp directory and file
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_count_dir')
test_file := os.join_path(temp_base, 'test_heroprompt_count_file.txt')
os.mkdir_all(test_dir)!
os.write_file(test_file, 'content')!
defer {
os.rmdir_all(test_dir) or {}
os.rm(test_file) or {}
delete(name: 'test_count_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_count_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Initially empty
assert ws.item_count() == 0
// Add directory
ws.add_directory(path: test_dir)!
assert ws.item_count() == 1
// Add file
ws.add_file(path: test_file)!
assert ws.item_count() == 2
}

View File

@@ -1,28 +1,165 @@
# heroprompt
# Heroprompt Module
To get started
A hierarchical workspace-based system for organizing code files and directories for AI code analysis. The module follows a clean hierarchical API structure: HeroPrompt → Workspace → Directory.
## Features
- **Hierarchical API**: Clean three-level structure (HeroPrompt → Workspace → Directory)
- **Workspace Management**: Create and manage multiple workspaces within a HeroPrompt instance
- **Directory Support**: Add entire directories with automatic file discovery
- **Flexible Scanning**: Support both automatic scanning (with gitignore) and manual file selection
- **Optional Naming**: Directory names are optional - automatically extracted from directory names
- **GitIgnore Support**: Automatic respect for `.gitignore` and `.heroignore` files during scanning
- **Redis Storage**: Persistent storage using Redis with automatic save on mutations
- **Centralized Logging**: Dual output to console and log file with configurable log levels
- **Concurrency Safe**: Thread-safe global state management with proper locking
## Hierarchical API Structure
```v
import freeflowuniverse.herolib.develop.heroprompt
// Example Usage:
// 1. Get or create a heroprompt instance
mut hp := heroprompt.get(name: 'my_heroprompt', create: true)!
// 1. Create a new workspace
mut workspace := heroprompt.new(name: 'my_workspace', path: os.getwd())!
// 2. Create workspaces from the heroprompt instance
mut workspace1 := hp.new_workspace(name: 'workspace1', description: 'First workspace')!
mut workspace2 := hp.new_workspace(name: 'workspace2', description: 'Second workspace')!
// 2. Add a directory to the workspace
workspace.add_dir(path: './my_project_dir')!
// 3. Add a file to the workspace
workspace.add_file(path: './my_project_dir/main.v')!
// 4. Generate a prompt
user_instructions := 'Explain the code in main.v'
prompt_output := workspace.prompt(text: user_instructions)
println(prompt_output)
// 3. Add directories to workspaces
// Name parameter is optional - if not provided, extracts from path (last directory name)
dir1 := workspace1.add_directory(
path: '/path/to/directory'
name: 'optional_custom_name' // Optional: defaults to last part of path
)!
// 4. Directory operations - Mode A: Automatic scanning (default)
// add_dir() automatically scans the directory respecting .gitignore
content := dir1.add_dir(path: 'src')! // Automatically scans by default
println('Scanned ${content.file_count} files, ${content.dir_count} directories')
// 5. Directory operations - Mode B: Manual selective addition
dir2 := workspace1.add_directory(path: '/path/to/dir2')!
file := dir2.add_file(path: 'specific_file.v')! // Add specific file
dir_content := dir2.add_dir(path: 'src', scan: false)! // Manual mode (no auto-scan)
// Note: Workspace operations (add_directory, remove_directory, add_file, remove_file)
// automatically save to Redis. No manual hp.save() call needed!
```
## API Overview
### HeroPrompt Factory Functions
- `get(name, fromdb, create, reset)` - Get or create a HeroPrompt instance (main entry point)
- `name: string` - Instance name (default: 'default')
- `fromdb: bool` - Force reload from Redis (default: false, uses cache)
- `create: bool` - Create if doesn't exist (default: false, returns error if not found)
- `reset: bool` - Delete and recreate if exists (default: false)
- `exists(name)` - Check if HeroPrompt instance exists
- `delete(name)` - Delete a HeroPrompt instance
- `list(fromdb)` - List all HeroPrompt instances
**Usage Examples:**
```v
// Get existing instance (error if not found)
hp := heroprompt.get(name: 'my_heroprompt')!
// Get or create instance
hp := heroprompt.get(name: 'my_heroprompt', create: true)!
// Force reload from Redis (bypass cache)
hp := heroprompt.get(name: 'my_heroprompt', fromdb: true)!
// Reset instance (delete and recreate)
hp := heroprompt.get(name: 'my_heroprompt', reset: true)!
```
### HeroPrompt Methods
- `new_workspace(name, description)` - Create a new workspace
- `get_workspace(name)` - Get an existing workspace
- `list_workspaces()` - List all workspaces
- `delete_workspace(name)` - Delete a workspace
- `save()` - Save HeroPrompt instance to Redis
### Workspace Methods
- `add_directory(path, name, description)` - Add a directory (name is optional)
- `remove_directory(id/path/name)` - Remove a directory
- `get_directory(id)` - Get a directory by ID
- `list_directories()` - List all directories
- `add_file(path)` - Add a standalone file
- `remove_file(id/path/name)` - Remove a file
- `get_file(id)` - Get a file by ID
- `list_files()` - List all standalone files
- `item_count()` - Get total number of items (directories + files)
### Directory Methods
- `add_file(path)` - Add a specific file (relative or absolute path)
- `add_dir(path, scan: bool = true)` - Add all files from a specific directory
- `scan: true` (default) - Automatically scans the directory respecting .gitignore
- `scan: false` - Manual mode, returns empty content (for selective file addition)
- `file_count()` - Get number of files in directory
- `display_name()` - Get display name (includes git branch if available)
- `exists()` - Check if directory path still exists
- `refresh_git_info()` - Refresh git metadata
**Note:** The `scan()` method is now private and called automatically by `add_dir()` when `scan=true`.
## Testing
Run all tests:
```bash
v -enable-globals -no-skip-unused -stats test lib/develop/heroprompt
```
Run specific test files:
```bash
v -enable-globals -no-skip-unused test lib/develop/heroprompt/heroprompt_workspace_test.v
v -enable-globals -no-skip-unused test lib/develop/heroprompt/heroprompt_directory_test.v
v -enable-globals -no-skip-unused test lib/develop/heroprompt/heroprompt_file_test.v
```
## Logging Configuration
The module includes centralized logging with dual output (console + file):
```v
mut hp := heroprompt.new(name: 'my_heroprompt', create: true)!
// Configure log path (default: '/tmp/heroprompt_logs')
hp.log_path = '/custom/log/path'
// Suppress logging during tests
hp.run_in_tests = true
```
Log levels: `.error`, `.warning`, `.info`, `.debug`
## Breaking Changes
### v1.0.0
- **Repository → Directory**: The `Repository` struct and all related methods have been renamed to `Directory` to better reflect their purpose
- `add_repository()``add_directory()`
- `remove_repository()``remove_directory()`
- `get_repository()``get_directory()`
- `list_repositories()``list_directories()`
- **Auto-save**: Workspace mutation methods now automatically save to Redis. Manual `hp.save()` calls are no longer required after workspace operations
- **Time Fields**: All time fields now use `ourtime.OurTime` instead of `time.Time`
- **UUID IDs**: All entities now use UUID v4 for unique identifiers instead of custom hash-based IDs
## Examples
See the `examples/develop/heroprompt/` directory for comprehensive examples:
- **01_heroprompt_create_example.vsh** - Creating HeroPrompt instances with workspaces and directories
- **02_heroprompt_get_example.vsh** - Retrieving and working with existing instances
- **README.md** - Detailed guide on running the examples
Run the examples in order (create first, then get) to see the full workflow.

View File

@@ -8,4 +8,4 @@
<file_contents>
@{prompt.file_contents}
</file_contents>
</file_contents>

View File

@@ -110,5 +110,16 @@ pub fn print_info(txt string) {
c.reset()
}
// Print warning in yellow color
pub fn print_warn(txt string) {
mut c := get()
if c.prev_title || c.prev_item {
lf()
}
txt2 := trim(texttools.indent(txt, ' . '))
cprintln(foreground: .yellow, text: txt2)
c.reset()
}
// import freeflowuniverse.herolib.ui.console
// console.print_header()

View File

@@ -1,471 +0,0 @@
module ui
import veb
import json
import freeflowuniverse.herolib.develop.heroprompt as hp
import freeflowuniverse.herolib.develop.codewalker
import freeflowuniverse.herolib.core.pathlib
import os
// ============================================================================
// Types and Structures
// ============================================================================
struct DirResp {
path string
items []hp.ListItem
}
struct SearchResult {
name string
path string
full_path string
type_ string @[json: 'type']
}
struct SearchResponse {
query string
results []SearchResult
count string
}
struct RecursiveListResponse {
path string
children []map[string]string
}
// ============================================================================
// Utility Functions
// ============================================================================
fn expand_home_path(path string) string {
mut p := pathlib.get(path)
return p.absolute()
}
fn json_error(message string) string {
return '{"error":"${message}"}'
}
fn json_success() string {
return '{"ok":true}'
}
fn set_json_content_type(mut ctx Context) {
ctx.set_content_type('application/json')
}
fn get_workspace_or_error(name string, mut ctx Context) ?&hp.Workspace {
wsp := hp.get(name: name, create: false) or {
set_json_content_type(mut ctx)
ctx.text(json_error('workspace not found'))
return none
}
return wsp
}
// ============================================================================
// Search Functionality
// ============================================================================
fn search_files_recursive(base_path string, query string) []SearchResult {
mut results := []SearchResult{}
query_lower := query.to_lower()
// Create ignore matcher for consistent filtering
ignore_matcher := codewalker.gitignore_matcher_new()
search_directory_with_ignore(base_path, base_path, query_lower, &ignore_matcher, mut
results)
return results
}
fn search_directory_with_ignore(base_path string, current_path string, query_lower string, ignore_matcher &codewalker.IgnoreMatcher, mut results []SearchResult) {
entries := os.ls(current_path) or { return }
for entry in entries {
full_path := os.join_path(current_path, entry)
// Calculate relative path for ignore checking
mut rel_path := full_path
if full_path.starts_with(base_path) {
rel_path = full_path[base_path.len..]
if rel_path.starts_with('/') {
rel_path = rel_path[1..]
}
}
// Check if this entry should be ignored
if ignore_matcher.is_ignored(rel_path) {
continue
}
// Check if filename or path matches search query
if entry.to_lower().contains(query_lower) || rel_path.to_lower().contains(query_lower) {
results << SearchResult{
name: entry
path: rel_path
full_path: full_path
type_: if os.is_dir(full_path) { 'directory' } else { 'file' }
}
}
// Recursively search subdirectories
if os.is_dir(full_path) {
search_directory_with_ignore(base_path, full_path, query_lower, ignore_matcher, mut
results)
}
}
}
// ============================================================================
// Workspace Management API Endpoints
// ============================================================================
// List all workspaces
@['/api/heroprompt/workspaces'; get]
pub fn (app &App) api_heroprompt_list_workspaces(mut ctx Context) veb.Result {
mut names := []string{}
ws := hp.list_workspaces_fromdb() or { []&hp.Workspace{} }
for w in ws {
names << w.name
}
set_json_content_type(mut ctx)
return ctx.text(json.encode(names))
}
// Create a new workspace
@['/api/heroprompt/workspaces'; post]
pub fn (app &App) api_heroprompt_create_workspace(mut ctx Context) veb.Result {
name_input := ctx.form['name'] or { '' }
// Validate workspace name
mut name := name_input.trim(' \t\n\r')
if name.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('workspace name is required'))
}
// Create workspace
wsp := hp.get(name: name, create: true) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('create failed'))
}
set_json_content_type(mut ctx)
return ctx.text(json.encode({
'name': wsp.name
}))
}
// Get workspace details
@['/api/heroprompt/workspaces/:name'; get]
pub fn (app &App) api_heroprompt_get_workspace(mut ctx Context, name string) veb.Result {
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
set_json_content_type(mut ctx)
return ctx.text(json.encode({
'name': wsp.name
'selected_files': wsp.selected_children().len.str()
}))
}
// Update workspace
@['/api/heroprompt/workspaces/:name'; put]
pub fn (app &App) api_heroprompt_update_workspace(mut ctx Context, name string) veb.Result {
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
new_name := ctx.form['name'] or { name }
// Update the workspace
updated_wsp := wsp.update_workspace(name: new_name) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('failed to update workspace'))
}
set_json_content_type(mut ctx)
return ctx.text(json.encode({
'name': updated_wsp.name
}))
}
// Delete workspace (using POST for VEB framework compatibility)
@['/api/heroprompt/workspaces/:name/delete'; post]
pub fn (app &App) api_heroprompt_delete_workspace(mut ctx Context, name string) veb.Result {
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
// Delete the workspace
wsp.delete_workspace() or {
set_json_content_type(mut ctx)
return ctx.text(json_error('failed to delete workspace'))
}
set_json_content_type(mut ctx)
return ctx.text(json_success())
}
// ============================================================================
// File and Directory Operations API Endpoints
// ============================================================================
// List directory contents
@['/api/heroprompt/directory'; get]
pub fn (app &App) api_heroprompt_list_directory(mut ctx Context) veb.Result {
wsname := ctx.query['name'] or { 'default' }
path_q := ctx.query['path'] or { '' }
base_path := ctx.query['base'] or { '' }
if base_path.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('base path is required'))
}
wsp := get_workspace_or_error(wsname, mut ctx) or { return ctx.text('') }
items := wsp.list_dir(base_path, path_q) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('cannot list directory'))
}
set_json_content_type(mut ctx)
return ctx.text(json.encode(DirResp{
path: if path_q.len > 0 { path_q } else { base_path }
items: items
}))
}
// Get file content
@['/api/heroprompt/file'; get]
pub fn (app &App) api_heroprompt_get_file(mut ctx Context) veb.Result {
wsname := ctx.query['name'] or { 'default' }
path_q := ctx.query['path'] or { '' }
if path_q.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('path required'))
}
// Validate file exists and is readable
if !os.is_file(path_q) {
set_json_content_type(mut ctx)
return ctx.text(json_error('not a file'))
}
content := os.read_file(path_q) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('failed to read file'))
}
set_json_content_type(mut ctx)
return ctx.text(json.encode({
'language': detect_lang(path_q)
'content': content
}))
}
// Add file to workspace
@['/api/heroprompt/workspaces/:name/files'; post]
pub fn (app &App) api_heroprompt_add_file(mut ctx Context, name string) veb.Result {
path := ctx.form['path'] or { '' }
if path.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('path required'))
}
mut wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
wsp.add_file(path: path) or {
set_json_content_type(mut ctx)
return ctx.text(json_error(err.msg()))
}
set_json_content_type(mut ctx)
return ctx.text(json_success())
}
// Add directory to workspace
@['/api/heroprompt/workspaces/:name/dirs'; post]
pub fn (app &App) api_heroprompt_add_directory(mut ctx Context, name string) veb.Result {
path := ctx.form['path'] or { '' }
if path.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('path required'))
}
mut wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
wsp.add_dir(path: path) or {
set_json_content_type(mut ctx)
return ctx.text(json_error(err.msg()))
}
set_json_content_type(mut ctx)
return ctx.text(json_success())
}
// ============================================================================
// Prompt Generation and Search API Endpoints
// ============================================================================
// Generate prompt from workspace selection
@['/api/heroprompt/workspaces/:name/prompt'; post]
pub fn (app &App) api_heroprompt_generate_prompt(mut ctx Context, name string) veb.Result {
text := ctx.form['text'] or { '' }
selected_paths_json := ctx.form['selected_paths'] or { '[]' }
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
// Parse selected paths
selected_paths := json.decode([]string, selected_paths_json) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('invalid selected paths format'))
}
// Generate prompt with selected paths
prompt := wsp.prompt_with_selection(text: text, selected_paths: selected_paths) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('failed to generate prompt: ${err.msg()}'))
}
ctx.set_content_type('text/plain')
return ctx.text(prompt)
}
// Search files in workspace
@['/api/heroprompt/workspaces/:name/search'; get]
pub fn (app &App) api_heroprompt_search_files(mut ctx Context, name string) veb.Result {
query := ctx.query['q'] or { '' }
base_path := ctx.query['base'] or { '' }
// Validate input parameters
if query.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('search query required'))
}
if base_path.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('base path required for search'))
}
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
// Perform search using improved search function
results := search_files_recursive(base_path, query)
// Build response
response := SearchResponse{
query: query
results: results
count: results.len.str()
}
set_json_content_type(mut ctx)
return ctx.text(json.encode(response))
}
// Get workspace selected children
@['/api/heroprompt/workspaces/:name/children'; get]
pub fn (app &App) api_heroprompt_get_workspace_children(mut ctx Context, name string) veb.Result {
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
children := wsp.selected_children()
set_json_content_type(mut ctx)
return ctx.text(json.encode(children))
}
// Get all recursive children of a directory (for directory selection)
@['/api/heroprompt/workspaces/:name/list'; get]
pub fn (app &App) api_heroprompt_list_directory_recursive(mut ctx Context, name string) veb.Result {
path_q := ctx.query['path'] or { '' }
if path_q.len == 0 {
set_json_content_type(mut ctx)
return ctx.text(json_error('path parameter is required'))
}
wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') }
// Get all recursive children of the directory
children := get_recursive_directory_children(path_q) or {
set_json_content_type(mut ctx)
return ctx.text(json_error('failed to list directory: ${err.msg()}'))
}
// Build response
response := RecursiveListResponse{
path: path_q
children: children
}
set_json_content_type(mut ctx)
return ctx.text(json.encode(response))
}
// ============================================================================
// Directory Traversal Helper Functions
// ============================================================================
// Get all recursive children of a directory with proper gitignore filtering
fn get_recursive_directory_children(dir_path string) ![]map[string]string {
// Validate directory exists
if !os.exists(dir_path) {
return error('directory does not exist: ${dir_path}')
}
if !os.is_dir(dir_path) {
return error('path is not a directory: ${dir_path}')
}
// Create ignore matcher with default patterns for consistent filtering
ignore_matcher := codewalker.gitignore_matcher_new()
mut results := []map[string]string{}
collect_directory_children_recursive(dir_path, dir_path, &ignore_matcher, mut results) or {
return error('failed to collect directory children: ${err.msg()}')
}
return results
}
// Recursively collect all children with proper gitignore filtering
fn collect_directory_children_recursive(base_dir string, current_dir string, ignore_matcher &codewalker.IgnoreMatcher, mut results []map[string]string) ! {
entries := os.ls(current_dir) or { return error('cannot list directory: ${current_dir}') }
for entry in entries {
full_path := os.join_path(current_dir, entry)
// Calculate relative path from base directory for ignore checking
rel_path := calculate_relative_path(full_path, base_dir)
// Check if this entry should be ignored using proper gitignore logic
if ignore_matcher.is_ignored(rel_path) {
continue
}
// Add this entry to results using full absolute path to match tree format
results << {
'name': entry
'path': full_path
'type': if os.is_dir(full_path) { 'directory' } else { 'file' }
}
// If it's a directory, recursively collect its children
if os.is_dir(full_path) {
collect_directory_children_recursive(base_dir, full_path, ignore_matcher, mut
results) or {
// Continue on error to avoid stopping the entire operation
continue
}
}
}
}
// Calculate relative path from base directory
fn calculate_relative_path(full_path string, base_dir string) string {
mut rel_path := full_path
if full_path.starts_with(base_dir) {
rel_path = full_path[base_dir.len..]
if rel_path.starts_with('/') {
rel_path = rel_path[1..]
}
}
return rel_path
}

View File

@@ -1,6 +1,7 @@
// Global state
let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default';
let selected = new Set();
let selected = new Set(); // Selected file paths
let selectedDirs = new Set(); // Selected directory paths (for UI state only)
let expandedDirs = new Set();
let searchQuery = '';
@@ -144,21 +145,43 @@ class SimpleFileTree {
checkbox.addEventListener('change', async (e) => {
e.stopPropagation();
if (checkbox.checked) {
selected.add(path);
// If this is a directory, also select all its children
// For directories: mark as selected and select all children files
// For files: add the file to selection
if (item.type === 'directory') {
// Add directory to selectedDirs for UI state tracking
selectedDirs.add(path);
// Select all file children (adds to 'selected' set)
await this.selectDirectoryChildren(path, true);
// Auto-expand the directory and its checked subdirectories
// This will load subdirectories into the DOM
await this.expandDirectoryRecursive(path);
// NOW update subdirectory selection after they're in the DOM
this.updateSubdirectorySelection(path, true);
// Update all checkboxes to reflect the new state
this.updateVisibleCheckboxes();
} else {
// For files, update UI immediately since no async operation
selected.add(path);
this.updateSelectionUI();
}
} else {
selected.delete(path);
// If this is a directory, also deselect all its children
// For files: remove from selection
// For directories: deselect directory and all children
if (item.type === 'directory') {
// Remove directory from selectedDirs
selectedDirs.delete(path);
// Deselect all file children
await this.selectDirectoryChildren(path, false);
// Update subdirectory selection
this.updateSubdirectorySelection(path, false);
// Update all checkboxes to reflect the new state
this.updateVisibleCheckboxes();
// Optionally collapse the directory when unchecked
// (commented out to leave expanded for user convenience)
// if (this.expandedDirs.has(path)) {
// await this.toggleDirectory(path);
// }
} else {
// For files, update UI immediately since no async operation
selected.delete(path);
this.updateSelectionUI();
}
}
@@ -244,6 +267,57 @@ class SimpleFileTree {
this.expandedDirs.delete(dirPath);
if (expandBtn) expandBtn.innerHTML = '▶';
if (icon) icon.textContent = '📁';
} else {
// Loading succeeded - restore checkbox states for subdirectories
// Check if this directory or any parent is selected
const isSelected = selectedDirs.has(dirPath) || this.isParentDirectorySelected(dirPath);
if (isSelected) {
// Restore subdirectory selection states
this.updateSubdirectorySelection(dirPath, true);
}
// Update all visible checkboxes to reflect current state
this.updateVisibleCheckboxes();
}
}
}
// Expand a directory if it's not already expanded
async expandDirectory(dirPath) {
const isExpanded = this.expandedDirs.has(dirPath);
if (!isExpanded) {
await this.toggleDirectory(dirPath);
}
}
// Recursively expand a directory and all its checked subdirectories
async expandDirectoryRecursive(dirPath) {
// First, expand this directory
await this.expandDirectory(dirPath);
// Wait a bit for the DOM to update with children
await new Promise(resolve => setTimeout(resolve, 100));
// Find all subdirectories that are children of this directory
const childDirs = Array.from(document.querySelectorAll('.tree-item'))
.filter(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
// Check if this is a direct or indirect child directory
return itemType === 'directory' &&
itemPath !== dirPath &&
itemPath.startsWith(dirPath + '/');
});
// Recursively expand checked subdirectories
for (const childDir of childDirs) {
const childPath = childDir.dataset.path;
const checkbox = childDir.querySelector('.tree-checkbox');
// If the subdirectory checkbox is checked, expand it recursively
if (checkbox && checkbox.checked) {
await this.expandDirectoryRecursive(childPath);
}
}
}
@@ -403,30 +477,99 @@ class SimpleFileTree {
// Select or deselect all children of a directory recursively
async selectDirectoryChildren(dirPath, select) {
// First, get all children from API to update the selection state
// Get all children from API to update the selection state
// This only selects FILES, not directories (API returns only files)
await this.selectDirectoryChildrenFromAPI(dirPath, select);
// Then, update any currently visible children in the DOM
// Note: We don't call updateSubdirectorySelection() here because
// subdirectories might not be in the DOM yet. The caller should
// call it after expanding the directory.
// Update any currently visible children in the DOM
this.updateVisibleCheckboxes();
// Update the selection UI once at the end
this.updateSelectionUI();
}
// Update selectedDirs for all subdirectories under a path
updateSubdirectorySelection(dirPath, select) {
// Find all visible subdirectories under this path
const treeItems = document.querySelectorAll('.tree-item');
treeItems.forEach(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
// Check if this is a subdirectory of dirPath
if (itemType === 'directory' && itemPath !== dirPath && itemPath.startsWith(dirPath + '/')) {
if (select) {
selectedDirs.add(itemPath);
} else {
selectedDirs.delete(itemPath);
}
}
});
}
// Update all visible checkboxes to match the current selection state
updateVisibleCheckboxes() {
const treeItems = document.querySelectorAll('.tree-item');
treeItems.forEach(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
const checkbox = item.querySelector('.tree-checkbox');
if (checkbox && itemPath) {
// Set checkbox state based on current selection
checkbox.checked = selected.has(itemPath);
if (itemType === 'file') {
// For files: check if the file path is in selected set
checkbox.checked = selected.has(itemPath);
} else if (itemType === 'directory') {
// For directories: check if all file children are selected
checkbox.checked = this.areAllChildrenSelected(itemPath);
}
}
});
}
// Check if a directory should be checked
// A directory is checked if:
// 1. It's in the selectedDirs set (explicitly selected), OR
// 2. Any parent directory is in selectedDirs (cascading)
areAllChildrenSelected(dirPath) {
// Check if this directory is explicitly selected
if (selectedDirs.has(dirPath)) {
return true;
}
// Check if any parent directory is selected (cascading)
if (this.isParentDirectorySelected(dirPath)) {
return true;
}
return false;
}
// Check if any parent directory of this path is selected
isParentDirectorySelected(dirPath) {
// Walk up the directory tree
let currentPath = dirPath;
while (currentPath.includes('/')) {
// Get parent directory
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
// Check if parent is in selectedDirs
if (selectedDirs.has(parentPath)) {
return true;
}
currentPath = parentPath;
}
return false;
}
// Select directory children using API to get complete recursive list
async selectDirectoryChildrenFromAPI(dirPath, select) {
try {
@@ -733,14 +876,22 @@ class SimpleFileTree {
selectAll() {
qsa('.tree-checkbox').forEach(checkbox => {
checkbox.checked = true;
const path = checkbox.closest('.tree-item').dataset.path;
selected.add(path);
const treeItem = checkbox.closest('.tree-item');
const path = treeItem.dataset.path;
const type = treeItem.dataset.type;
// Add files to selected set, directories to selectedDirs set
if (type === 'file') {
selected.add(path);
} else if (type === 'directory') {
selectedDirs.add(path);
}
});
this.updateSelectionUI();
}
clearSelection() {
selected.clear();
selectedDirs.clear();
qsa('.tree-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
@@ -762,6 +913,26 @@ class SimpleFileTree {
this.loadedPaths.clear();
}
// Refresh a specific directory (collapse and re-expand to reload its contents)
async refreshDirectory(dirPath) {
const dirElement = document.querySelector(`[data-path="${dirPath}"][data-type="directory"]`);
if (!dirElement) {
console.warn('Directory element not found:', dirPath);
return;
}
const wasExpanded = this.expandedDirs.has(dirPath);
if (wasExpanded) {
// Collapse the directory
await this.toggleDirectory(dirPath);
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
// Re-expand to reload contents
await this.toggleDirectory(dirPath);
}
}
async search(query) {
searchQuery = query.toLowerCase().trim();
@@ -1344,8 +1515,26 @@ document.addEventListener('DOMContentLoaded', function () {
const refreshExplorerBtn = el('refreshExplorer');
if (refreshExplorerBtn) {
refreshExplorerBtn.addEventListener('click', async () => {
// Save currently expanded directories before refresh
const previouslyExpanded = new Set(expandedDirs);
// Reload workspace directories
await loadWorkspaceDirectories();
// Re-expand previously expanded directories
if (fileTree && previouslyExpanded.size > 0) {
// Wait a bit for the DOM to be ready
await new Promise(resolve => setTimeout(resolve, 100));
// Re-expand each previously expanded directory
for (const dirPath of previouslyExpanded) {
const dirElement = document.querySelector(`[data-path="${dirPath}"][data-type="directory"]`);
if (dirElement && !expandedDirs.has(dirPath)) {
// Expand this directory
await fileTree.toggleDirectory(dirPath);
}
}
}
});
}