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:
378
aiprompts/herolib_core/core_heroprompt.md
Normal file
378
aiprompts/herolib_core/core_heroprompt.md
Normal 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
|
||||
198
examples/develop/heroprompt/README.md
Normal file
198
examples/develop/heroprompt/README.md
Normal 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
|
||||
@@ -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}') }
|
||||
145
examples/develop/heroprompt/prompt_example.vsh
Executable file
145
examples/develop/heroprompt/prompt_example.vsh
Executable 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')
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
126
lib/develop/heroprompt/heroprompt.v
Normal file
126
lib/develop/heroprompt/heroprompt.v
Normal 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}')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
467
lib/develop/heroprompt/heroprompt_directory.v
Normal file
467
lib/develop/heroprompt/heroprompt_directory.v
Normal 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
|
||||
}
|
||||
}
|
||||
289
lib/develop/heroprompt/heroprompt_directory_test.v
Normal file
289
lib/develop/heroprompt/heroprompt_directory_test.v
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
118
lib/develop/heroprompt/heroprompt_file.v
Normal file
118
lib/develop/heroprompt/heroprompt_file.v
Normal 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]
|
||||
}
|
||||
150
lib/develop/heroprompt/heroprompt_file_test.v
Normal file
150
lib/develop/heroprompt/heroprompt_file_test.v
Normal 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('-')
|
||||
}
|
||||
48
lib/develop/heroprompt/heroprompt_logging.v
Normal file
48
lib/develop/heroprompt/heroprompt_logging.v
Normal 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}') }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
330
lib/develop/heroprompt/heroprompt_prompt.v
Normal file
330
lib/develop/heroprompt/heroprompt_prompt.v
Normal 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
|
||||
}
|
||||
192
lib/develop/heroprompt/heroprompt_test.v
Normal file
192
lib/develop/heroprompt/heroprompt_test.v
Normal 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
781
lib/develop/heroprompt/heroprompt_workspace.v
Executable file → Normal 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 } // 0–99
|
||||
|
||||
return '${adj}_${noun}_${number}'
|
||||
return selected
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
<file_contents>
|
||||
@{prompt.file_contents}
|
||||
</file_contents>
|
||||
</file_contents>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user