Compare commits

...

16 Commits

Author SHA1 Message Date
e34ba394b9 Merge branch 'development' into development_heroprompt_v2
* development: (182 commits)
  ...
  ...
  fix ci
  ...
  fix: Ignore regex_convert_test.v test
  refactor: Replace codewalker with pathlib and filemap
  test: Ignore virt/heropods/network_test.v in CI
  feat: implement container keep-alive feature
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  test: Add comprehensive heropods network and container tests
  ...
  ...
  codewalker
  ...

# Conflicts:
#	lib/develop/heroprompt/heroprompt_workspace.v
2025-11-25 18:54:14 +01:00
43308dfbe1 Merge branch 'development' into development_heroprompt_v2
* development:
  ...
  feat: Update site page source references
  feat: Add announcement bar configuration
  ...
  Update the pages
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  ...
  fix: Improve Docusaurus link generation logic
2025-10-21 06:45:24 +02:00
Mahmoud-Emad
e8904ea1ce chore: Formating the pages 2025-10-15 15:38:13 +03:00
Mahmoud-Emad
3d25fe0f04 refactor: Update import paths and save logic
- Update import paths from `freeflowuniverse.herolib` to `incubaid.herolib`
- Ensure `ws.parent.save()` is only called when `ws.parent` is present
- Remove redundant symlink cleanup for `freeflowuniverse.herolib`
2025-10-15 15:03:25 +03:00
Mahmoud-Emad
d91957b945 Merge branch 'development' into development_heroprompt_v2 2025-10-15 14:40:14 +03:00
Mahmoud-Emad
923f8c24e7 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.
2025-10-12 12:16:52 +03:00
Mahmoud-Emad
40ad68e0ff Merge branch 'development' into development_heroprompt_v2 2025-10-10 15:25:22 +03:00
Mahmoud-Emad
1762387301 feat: add recursive file and directory search
- Introduce SearchResult struct for search items
- Implement search_files and recursive search logic
- Migrate heroprompt file operations to pathlib
- Update expand_home_path to use pathlib
- Integrate codewalker and pathlib in example script
2025-09-14 12:42:43 +03:00
ea9286687d Merge branch 'development' into development_heroprompt_v2
* development:
  Trigger security scan
  Add Github Actions Security workflow
2025-09-11 08:08:27 +04:00
Mahmoud-Emad
cc837a1427 feat: enhance file selection and prompt generation
- Add gitignore filtering to file tree and search
- Introduce recursive directory listing API
- Enable recursive directory selection in UI
- Pass selected paths directly for prompt generation
- Refactor API endpoint names and error handling
2025-09-09 16:31:08 +03:00
Mahmoud-Emad
154c08411c refactor: Simplify prompt content and file map generation
- Extract recursive file tree logic into new helper function
- Remove explicit file content generation from prompt
- Simplify `build_file_map` to only generate file trees
- Eliminate file metadata calculation from `build_file_map`
- Comment out extensive example workspace operations
2025-09-09 11:50:12 +03:00
1870f2a7ce Merge branch 'development' into development_heroprompt_v2
* development:
  ...
  ...
  add example heromodels call
  add example and heromodels openrpc server
  remove server from gitignore
  clean up and fix openrpc server implementation
  Test the workflow
2025-09-09 06:31:24 +04:00
Mahmoud-Emad
ff92f6eff2 feat: Initialize Workspace with metadata fields
- Initialize `children`, `created`, `updated`, `is_saved`
- Add `time` import for timestamp fields
- Remove unused `ui.console` import
- Update package version constant to `1.0.0`
2025-09-08 15:45:17 +03:00
Mahmoud-Emad
eeb5e207f2 Merge branch 'development' into development_heroprompt_v2 2025-09-08 14:55:31 +03:00
Mahmoud-Emad
09b595948d Merge branch 'development' into development_heroprompt_v2 2025-09-07 14:52:43 +03:00
Mahmoud-Emad
63c0b81fc9 feat: Support multi-root workspaces
- Remove `base_path` from Workspace struct and APIs
- Enable adding multiple root directories to a workspace
- Update file tree UI to display all workspace roots
- Refactor file map generation for multi-root display
- Improve prompt output clipboard copy with status
2025-09-07 14:40:17 +03:00
25 changed files with 3767 additions and 628 deletions

View File

@@ -0,0 +1,378 @@
# HeroPrompt Module
The `heroprompt` module provides a hierarchical workspace-based system for organizing code files and generating structured AI prompts. It enables developers to select files from multiple directories and generate formatted prompts for AI code analysis.
## Key Features
- **Hierarchical Organization**: HeroPrompt → Workspace → Directory → Files
- **Redis Persistence**: All data persists across sessions using Redis
- **Factory Pattern**: Clean API with `get()`, `delete()`, `exists()`, `list()` functions
- **File Selection**: Select specific files or entire directories for analysis
- **Active Workspace**: Manage multiple workspaces with one active at a time
- **Prompt Generation**: Generate structured prompts with file maps, contents, and instructions
- **Template-Based**: Uses V templates for consistent prompt formatting
## Basic Usage
### 1. Getting Started
```v
import incubaid.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 incubaid.herolib.develop.heroprompt
mut hp := heroprompt.get(name: 'my_app', create: true)!
mut ws := hp.new_workspace(name: 'backend')!
mut src_dir := ws.add_directory(path: '/path/to/src', name: 'source', scan: true)!
src_dir.select_file(path: '/path/to/src/main.v')!
prompt := ws.generate_prompt(instruction: 'Review the code')!
println(prompt)
heroprompt.delete(name: 'my_app')!
```
## Tips
- Use `heroprompt.delete()` at start for fresh state
- First workspace is automatically active
- Changes auto-save to Redis
- Use `scan: true` to discover all files
- Create separate workspaces for different contexts

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env -S v -n -w -gc none -cg -cc tcc -d use_openssl -enable-globals run
import incubaid.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/incubaid/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/incubaid/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/incubaid/herolib/examples/develop/heroprompt/README.md'
)!
println(' Selected: README.md')
examples_dir.select_file(
path: '${homepath}/code/github/incubaid/herolib/examples/develop/heroprompt/prompt_example.vsh'
)!
println(' Selected: prompt_example.vsh')
// Select all files from the library directory
lib_dir.select_all()!
println(' Selected all files in library directory\n')
// ============================================================================
// STEP 6: Generate AI Prompt
// ============================================================================
// Generate a complete prompt with file map, file contents, and instructions
// The prompt automatically includes only the selected files
println('Step 6: Generating AI prompt...')
prompt := workspace.generate_prompt(
instruction: 'Review the selected files and provide suggestions for improvements.'
)!
println(' Generated prompt')
println(' Total length: ${prompt.len} characters\n')
// ============================================================================
// STEP 7: Display Prompt Preview
// ============================================================================
println('Step 7: Prompt preview (first 800 characters)...')
preview_len := if prompt.len > 800 { 800 } else { prompt.len }
println(prompt[..preview_len])
// ============================================================================
// STEP 8: Alternative - Get Active Workspace
// ============================================================================
// You can retrieve the active workspace without knowing its name
println('Step 8: Working with active workspace...')
mut active_ws := hp.get_active_workspace()!
println(' Retrieved active workspace: ${active_ws.name}')
println(' Directories: ${active_ws.directories.len}')
println(' Files: ${active_ws.files.len}\n')
// ============================================================================
// STEP 9: Set Different Active Workspace
// ============================================================================
// You can create multiple workspaces and switch between them
println('Step 9: Creating and switching workspaces...')
// Create a second workspace
mut workspace2 := hp.new_workspace(
name: 'documentation'
description: 'Documentation workspace'
is_active: false
)!
println(' Created workspace: ${workspace2.name}')
// Switch active workspace
hp.set_active_workspace('documentation')!
println(' Set active workspace to: documentation')
// Verify the switch
active_ws = hp.get_active_workspace()!
println(' Current active workspace: ${active_ws.name}\n')
// ============================================================================
// STEP 10: Cleanup
// ============================================================================
println('Step 10: Cleanup...')
heroprompt.delete(name: 'prompt_demo')!
println(' Deleted instance\n')

View File

@@ -45,7 +45,7 @@ fn addtoscript(tofind string, toadd string) ! {
// Reset symlinks (cleanup)
println('Resetting all symlinks...')
os.rm('${os.home_dir()}/.vmodules/incubaid/herolib') or {}
os.rm('${os.home_dir()}/.vmodules/freeflowuniverse/herolib') or {}
os.rm('${os.home_dir()}/.vmodules/incubaid/herolib') or {}
// Create necessary directories
os.mkdir_all('${os.home_dir()}/.vmodules/incubaid') or {

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
module heroprompt
import incubaid.herolib.core.pathlib
import os
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')
}
content := os.read_file(chl.path.path)!
return content
}

View File

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

View File

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

View File

@@ -2,11 +2,10 @@ module heroprompt
import incubaid.herolib.core.base
import incubaid.herolib.core.playbook { PlayBook }
import incubaid.herolib.ui.console
import json
__global (
heroprompt_global map[string]&Workspace
heroprompt_global shared map[string]&HeroPrompt
heroprompt_default string
)
@@ -18,38 +17,68 @@ pub mut:
name string = 'default'
fromdb bool // will load from filesystem
create bool // default will not create if not exist
reset bool // will delete and recreate if exists
}
pub fn new(args ArgsGet) !&Workspace {
mut obj := Workspace{
pub fn new(args ArgsGet) !&HeroPrompt {
mut obj := HeroPrompt{
name: args.name
}
set(obj)!
return get(name: args.name)!
}
pub fn get(args ArgsGet) !&Workspace {
pub fn get(args ArgsGet) !&HeroPrompt {
mut context := base.context()!
heroprompt_default = args.name
if args.fromdb || args.name !in heroprompt_global {
mut r := context.redis()!
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
}
// 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
if r.hexists('context:heroprompt', args.name)! {
// Load existing instance from Redis
data := r.hget('context:heroprompt', args.name)!
if data.len == 0 {
print_backtrace()
return error('Workspace 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 {
print_backtrace()
return error("Workspace 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 {
print_backtrace()
@@ -58,9 +87,10 @@ pub fn get(args ArgsGet) !&Workspace {
}
// 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))!
@@ -77,6 +107,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]
@@ -86,15 +121,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')!
@@ -104,18 +141,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
}
@@ -136,5 +189,4 @@ pub fn play(mut plbook PlayBook) ! {
// switch instance to be used for heroprompt
pub fn switch(name string) {
heroprompt_default = name
}

View File

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

View File

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

View File

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

View File

@@ -1,50 +1,94 @@
module heroprompt
import time
import incubaid.herolib.core.playbook
import incubaid.herolib.data.ourtime
import incubaid.herolib.data.encoderhero
import incubaid.herolib.core.logger
import rand
pub const version = '0.0.0'
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
base_path string // Base path of the workspace
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')!
base_path: p.get_default('base_path', '')!
created: time.now()
updated: time.now()
children: []HeropromptChild{}
}
pub fn heroscript_loads(heroscript string) !HeroPrompt {
mut obj := encoderhero.decode[HeroPrompt](heroscript)!
return obj
}

View File

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

View File

@@ -0,0 +1,192 @@
module heroprompt
import incubaid.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
}

View File

@@ -0,0 +1,302 @@
module heroprompt
import os
import incubaid.herolib.core.base
// 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()
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 {
os.rmdir_all(test_dir) or {}
delete(name: 'test_add_repo_hp') or {}
}
// 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 {}
}
// 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')!
// Add directory without custom name
repo := ws.add_directory(path: test_dir)!
// Name should be extracted from directory name
assert repo.name == 'my_custom_dir_name'
}
// 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 duplicate directory'
}
// 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()
test_dir := os.join_path(temp_base, 'test_heroprompt_remove_repo')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_remove_repo_hp') or {}
}
// 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 directory
repo := ws.add_directory(path: test_dir)!
assert ws.directories.len == 1
// Remove by ID
ws.remove_directory(id: repo.id)!
assert ws.directories.len == 0
}
// Test workspace: remove directory by path
fn test_workspace_remove_directory_by_path() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_remove_path_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_remove_path')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_remove_path_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_remove_path_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
ws.add_directory(path: test_dir)!
assert ws.directories.len == 1
// Remove by path
ws.remove_directory(path: test_dir)!
assert ws.directories.len == 0
}
// Test workspace: get directory
fn test_workspace_get_directory() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_get_repo_hp') or {}
// Create temp directory
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_get_repo')
os.mkdir_all(test_dir)!
defer {
os.rmdir_all(test_dir) or {}
delete(name: 'test_get_repo_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_get_repo_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directory
repo := ws.add_directory(path: test_dir)!
// Get directory
retrieved_repo := ws.get_directory(repo.id)!
assert retrieved_repo.id == repo.id
assert retrieved_repo.path == repo.path
}
// Test workspace: list directories
fn test_workspace_list_directories() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_list_repos_hp') or {}
// Create temp directories
temp_base := os.temp_dir()
test_dir1 := os.join_path(temp_base, 'test_heroprompt_list_1')
test_dir2 := os.join_path(temp_base, 'test_heroprompt_list_2')
os.mkdir_all(test_dir1)!
os.mkdir_all(test_dir2)!
defer {
os.rmdir_all(test_dir1) or {}
os.rmdir_all(test_dir2) or {}
delete(name: 'test_list_repos_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_list_repos_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add directories
ws.add_directory(path: test_dir1)!
ws.add_directory(path: test_dir2)!
// List directories
repos := ws.list_directories()
assert repos.len == 2
}
// Test workspace: add file
fn test_workspace_add_file() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_add_file_hp') or {}
// Create temp file
temp_base := os.temp_dir()
test_file := os.join_path(temp_base, 'test_heroprompt_add_file.txt')
os.write_file(test_file, 'test file content')!
defer {
os.rm(test_file) or {}
delete(name: 'test_add_file_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_add_file_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Add file
file := ws.add_file(path: test_file)!
assert ws.files.len == 1
assert file.content == 'test file content'
assert file.path == os.real_path(test_file)
}
// Test workspace: item count
fn test_workspace_item_count() ! {
mut ctx := base.context()!
mut r := ctx.redis()!
// Clean up
r.hdel('context:heroprompt', 'test_count_hp') or {}
// Create temp directory and file
temp_base := os.temp_dir()
test_dir := os.join_path(temp_base, 'test_heroprompt_count_dir')
test_file := os.join_path(temp_base, 'test_heroprompt_count_file.txt')
os.mkdir_all(test_dir)!
os.write_file(test_file, 'content')!
defer {
os.rmdir_all(test_dir) or {}
os.rm(test_file) or {}
delete(name: 'test_count_hp') or {}
}
// Create heroprompt instance and workspace
mut hp := get(name: 'test_count_hp', create: true)!
hp.run_in_tests = true // Suppress logging during tests
mut ws := hp.new_workspace(name: 'test_ws')!
// Initially empty
assert ws.item_count() == 0
// Add directory
ws.add_directory(path: test_dir)!
assert ws.item_count() == 1
// Add file
ws.add_file(path: test_file)!
assert ws.item_count() == 2
}

View File

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

View File

@@ -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, '')
}
@@ -84,6 +85,13 @@ pub:
// build_file_tree_fs builds a file system tree for given root directories
pub fn build_file_tree_fs(roots []string, prefix string) string {
// Create ignore matcher with default patterns
ignore_matcher := gitignore_matcher_new()
return build_file_tree_fs_with_ignore(roots, prefix, &ignore_matcher)
}
// build_file_tree_fs_with_ignore builds a file system tree with ignore pattern filtering
pub fn build_file_tree_fs_with_ignore(roots []string, prefix string, ignore_matcher &IgnoreMatcher) string {
mut out := ''
for i, root in roots {
if !os.is_dir(root) {
@@ -92,41 +100,91 @@ pub fn build_file_tree_fs(roots []string, prefix string) string {
connector := if i == roots.len - 1 { ' ' } else { ' ' }
out += '${prefix}${connector}${os.base(root)}\n'
child_prefix := if i == roots.len - 1 { prefix + ' ' } else { prefix + ' ' }
// list children under root
entries := os.ls(root) or { []string{} }
// sort: dirs first then files
mut dirs := []string{}
mut files := []string{}
for e in entries {
fp := os.join_path(root, e)
if os.is_dir(fp) {
dirs << fp
} else if os.is_file(fp) {
files << fp
}
out += build_file_tree_fs_recursive_with_ignore(root, child_prefix, '', ignore_matcher)
}
return out
}
// build_file_tree_fs_recursive builds the contents of a directory without adding the directory name itself
fn build_file_tree_fs_recursive(root string, prefix string) string {
// Create ignore matcher with default patterns for backward compatibility
ignore_matcher := gitignore_matcher_new()
return build_file_tree_fs_recursive_with_ignore(root, prefix, '', &ignore_matcher)
}
// build_file_tree_fs_recursive_with_ignore builds the contents of a directory with ignore pattern filtering
fn build_file_tree_fs_recursive_with_ignore(root string, prefix string, base_rel_path string, ignore_matcher &IgnoreMatcher) string {
mut out := ''
// list children under root
entries := os.ls(root) or { []string{} }
// sort: dirs first then files
mut dirs := []string{}
mut files := []string{}
for e in entries {
fp := os.join_path(root, e)
// Calculate relative path for ignore checking
rel_path := if base_rel_path.len > 0 {
if base_rel_path.ends_with('/') { base_rel_path + e } else { base_rel_path + '/' + e }
} else {
e
}
dirs.sort()
files.sort()
// files
for j, f in files {
file_connector := if j == files.len - 1 && dirs.len == 0 {
' '
// Check if this entry should be ignored
mut should_ignore := ignore_matcher.is_ignored(rel_path)
if os.is_dir(fp) && !should_ignore {
// Also check directory pattern with trailing slash
should_ignore = ignore_matcher.is_ignored(rel_path + '/')
}
if should_ignore {
continue
}
if os.is_dir(fp) {
dirs << fp
} else if os.is_file(fp) {
files << fp
}
}
dirs.sort()
files.sort()
// files
for j, f in files {
file_connector := if j == files.len - 1 && dirs.len == 0 {
' '
} else {
' '
}
out += '${prefix}${file_connector}${os.base(f)} *\n'
}
// subdirectories
for j, d in dirs {
sub_connector := if j == dirs.len - 1 { ' ' } else { ' ' }
out += '${prefix}${sub_connector}${os.base(d)}\n'
sub_prefix := if j == dirs.len - 1 {
prefix + ' '
} else {
prefix + ' '
}
// Calculate new relative path for subdirectory
dir_name := os.base(d)
new_rel_path := if base_rel_path.len > 0 {
if base_rel_path.ends_with('/') {
base_rel_path + dir_name
} else {
' '
base_rel_path + '/' + dir_name
}
out += '${child_prefix}${file_connector}${os.base(f)} *\n'
}
// subdirectories
for j, d in dirs {
sub_connector := if j == dirs.len - 1 { ' ' } else { ' ' }
out += '${child_prefix}${sub_connector}${os.base(d)}\n'
sub_prefix := if j == dirs.len - 1 {
child_prefix + ' '
} else {
child_prefix + ' '
}
out += build_file_tree_fs([d], sub_prefix)
} else {
dir_name
}
out += build_file_tree_fs_recursive_with_ignore(d, sub_prefix, new_rel_path, ignore_matcher)
}
return out
}
@@ -149,3 +207,72 @@ pub fn build_file_tree_selected(files []string, base_root string) string {
rels.sort()
return tree_from_rel_paths(rels, '')
}
// SearchResult represents a search result item
pub struct SearchResult {
pub:
name string // filename
path string // relative path from base
full_path string // absolute path
typ string // 'file' or 'directory'
}
// search_files searches for files and directories matching a query string
// - base_path: the root directory to search from
// - query: the search query (case-insensitive substring match)
// - ignore_matcher: optional ignore matcher to filter results (can be null)
// Returns a list of SearchResult items
pub fn search_files(base_path string, query string, ignore_matcher &IgnoreMatcher) ![]SearchResult {
if query.len == 0 {
return []SearchResult{}
}
query_lower := query.to_lower()
mut results := []SearchResult{}
search_directory_recursive(base_path, base_path, query_lower, ignore_matcher, mut
results)!
return results
}
// search_directory_recursive recursively searches directories for matching files
fn search_directory_recursive(dir_path string, base_path string, query_lower string, ignore_matcher &IgnoreMatcher, mut results []SearchResult) ! {
entries := os.ls(dir_path) or { return }
for entry in entries {
full_path := os.join_path(dir_path, entry)
// Calculate relative path from base_path
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 unsafe { ignore_matcher != 0 } {
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
typ: if os.is_dir(full_path) { 'directory' } else { 'file' }
}
}
// Recursively search subdirectories
if os.is_dir(full_path) {
search_directory_recursive(full_path, base_path, query_lower, ignore_matcher, mut
results)!
}
}
}

View File

@@ -110,5 +110,13 @@ pub fn print_info(txt string) {
c.reset()
}
// import incubaid.herolib.ui.console
// console.print_header()
// 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()
}

View File

@@ -1,305 +0,0 @@
module ui
import veb
import os
import json
import incubaid.herolib.develop.heroprompt as hp
// Types
struct DirResp {
path string
items []hp.ListItem
}
// Utility functions
fn expand_home_path(path string) string {
if path.starts_with('~') {
home := os.home_dir()
return os.join_path(home, path.all_after('~'))
}
return path
}
fn json_error(message string) string {
return '{"error":"${message}"}'
}
fn json_success() string {
return '{"ok":true}'
}
// Recursive search function
fn search_directory(dir_path string, base_path string, query_lower string, mut results []map[string]string) {
entries := os.ls(dir_path) or { return }
for entry in entries {
full_path := os.join_path(dir_path, entry)
// Skip hidden files and common ignore patterns
if entry.starts_with('.') || entry == 'node_modules' || entry == 'target'
|| entry == 'build' {
continue
}
// Get relative path from workspace base
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 filename or path matches search query
if entry.to_lower().contains(query_lower) || rel_path.to_lower().contains(query_lower) {
results << {
'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(full_path, base_path, query_lower, mut results)
}
}
}
// APIs
@['/api/heroprompt/workspaces'; get]
pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result {
mut names := []string{}
ws := hp.list_workspaces_fromdb() or { []&hp.Workspace{} }
for w in ws {
names << w.name
}
ctx.set_content_type('application/json')
return ctx.text(json.encode(names))
}
// @['/api/heroprompt/workspaces'; post]
// pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result {
// name_input := ctx.form['name'] or { '' }
// base_path_in := ctx.form['base_path'] or { '' }
// if base_path_in.len == 0 {
// return ctx.text(json_error('base_path required'))
// }
// base_path := expand_home_path(base_path_in)
// // If no name provided, generate a random name
// mut name := name_input.trim(' \t\n\r')
// if name.len == 0 {
// name = hp.generate_random_workspace_name()
// }
// wsp := hp.get(name: name, create: true, path: base_path) or {
// return ctx.text(json_error('create failed'))
// }
// ctx.set_content_type('application/json')
// return ctx.text(json.encode({
// 'name': wsp.name
// 'base_path': wsp.base_path
// }))
// }
@['/api/heroprompt/workspaces/:name'; get]
pub fn (app &App) api_heroprompt_get(mut ctx Context, name string) veb.Result {
wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
ctx.set_content_type('application/json')
return ctx.text(json.encode({
'name': wsp.name
'base_path': wsp.base_path
'selected_files': wsp.selected_children().len.str()
}))
}
@['/api/heroprompt/workspaces/:name'; put]
pub fn (app &App) api_heroprompt_update(mut ctx Context, name string) veb.Result {
wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
new_name := ctx.form['name'] or { name }
new_base_path_in := ctx.form['base_path'] or { wsp.base_path }
new_base_path := expand_home_path(new_base_path_in)
// Update the workspace using the update_workspace method
updated_wsp := wsp.update_workspace(
name: new_name
base_path: new_base_path
) or { return ctx.text(json_error('failed to update workspace')) }
ctx.set_content_type('application/json')
return ctx.text(json.encode({
'name': updated_wsp.name
'base_path': updated_wsp.base_path
}))
}
// Delete endpoint using POST (VEB framework compatibility)
@['/api/heroprompt/workspaces/:name/delete'; post]
pub fn (app &App) api_heroprompt_delete(mut ctx Context, name string) veb.Result {
wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
// Delete the workspace
wsp.delete_workspace() or { return ctx.text(json_error('failed to delete workspace')) }
ctx.set_content_type('application/json')
return ctx.text(json_success())
}
@['/api/heroprompt/directory'; get]
pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result {
wsname := ctx.query['name'] or { 'default' }
path_q := ctx.query['path'] or { '' }
mut wsp := hp.get(name: wsname, create: false) or {
return ctx.text(json_error('workspace not found'))
}
items := wsp.list_dir(path_q) or { return ctx.text(json_error('cannot list directory')) }
ctx.set_content_type('application/json')
return ctx.text(json.encode(DirResp{
path: if path_q.len > 0 { path_q } else { wsp.base_path }
items: items
}))
}
@['/api/heroprompt/file'; get]
pub fn (app &App) api_heroprompt_file(mut ctx Context) veb.Result {
wsname := ctx.query['name'] or { 'default' }
path_q := ctx.query['path'] or { '' }
if path_q.len == 0 {
return ctx.text(json_error('path required'))
}
mut base := ''
if wsp := hp.get(name: wsname, create: false) {
base = wsp.base_path
}
mut file_path := if !os.is_abs_path(path_q) && base.len > 0 {
os.join_path(base, path_q)
} else {
path_q
}
if !os.is_file(file_path) {
return ctx.text(json_error('not a file'))
}
content := os.read_file(file_path) or { return ctx.text(json_error('failed to read')) }
ctx.set_content_type('application/json')
return ctx.text(json.encode({
'language': detect_lang(file_path)
'content': content
}))
}
@['/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 {
return ctx.text(json_error('path required'))
}
mut wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
wsp.add_file(path: path) or { return ctx.text(json_error(err.msg())) }
return ctx.text(json_success())
}
@['/api/heroprompt/workspaces/:name/dirs'; post]
pub fn (app &App) api_heroprompt_add_dir(mut ctx Context, name string) veb.Result {
path := ctx.form['path'] or { '' }
if path.len == 0 {
return ctx.text(json_error('path required'))
}
mut wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
wsp.add_dir(path: path) or { return ctx.text(json_error(err.msg())) }
return ctx.text(json_success())
}
@['/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 { '' }
mut wsp := hp.get(name: name, create: false) or {
ctx.set_content_type('application/json')
return ctx.text(json_error('workspace not found'))
}
prompt := wsp.prompt(text: text)
ctx.set_content_type('text/plain')
return ctx.text(prompt)
}
@['/api/heroprompt/workspaces/:name/selection'; post]
pub fn (app &App) api_heroprompt_sync_selection(mut ctx Context, name string) veb.Result {
paths_json := ctx.form['paths'] or { '[]' }
mut wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
// Clear current selection
wsp.children.clear()
// Parse paths and add them to workspace
paths := json.decode([]string, paths_json) or {
return ctx.text(json_error('invalid paths format'))
}
for path in paths {
if os.is_file(path) {
wsp.add_file(path: path) or {
continue // Skip files that can't be added
}
} else if os.is_dir(path) {
wsp.add_dir(path: path) or {
continue // Skip directories that can't be added
}
}
}
return ctx.text(json_success())
}
@['/api/heroprompt/workspaces/:name/search'; get]
pub fn (app &App) api_heroprompt_search(mut ctx Context, name string) veb.Result {
query := ctx.query['q'] or { '' }
if query.len == 0 {
return ctx.text(json_error('search query required'))
}
wsp := hp.get(name: name, create: false) or {
return ctx.text(json_error('workspace not found'))
}
// Simple recursive file search implementation
mut results := []map[string]string{}
query_lower := query.to_lower()
// Recursive function to search files
search_directory(wsp.base_path, wsp.base_path, query_lower, mut results)
ctx.set_content_type('application/json')
// Manually build JSON response to avoid encoding issues
mut json_results := '['
for i, result in results {
if i > 0 {
json_results += ','
}
json_results += '{'
json_results += '"name":"${result['name']}",'
json_results += '"path":"${result['path']}",'
json_results += '"full_path":"${result['full_path']}",'
json_results += '"type":"${result['type']}"'
json_results += '}'
}
json_results += ']'
response := '{"query":"${query}","results":${json_results},"count":"${results.len}"}'
return ctx.text(response)
}

View File

@@ -1,6 +1,7 @@
// Global state
let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default';
let selected = new Set();
let selected = new Set(); // Selected file paths
let selectedDirs = new Set(); // Selected directory paths (for UI state only)
let expandedDirs = new Set();
let searchQuery = '';
@@ -141,14 +142,49 @@ class SimpleFileTree {
checkbox.type = 'checkbox';
checkbox.className = 'tree-checkbox';
checkbox.checked = selected.has(path);
checkbox.addEventListener('change', (e) => {
checkbox.addEventListener('change', async (e) => {
e.stopPropagation();
if (checkbox.checked) {
selected.add(path);
// 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 {
selected.add(path);
this.updateSelectionUI();
}
} else {
selected.delete(path);
// 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 {
selected.delete(path);
this.updateSelectionUI();
}
}
this.updateSelectionUI();
});
// Expand/collapse button for directories
@@ -218,11 +254,71 @@ class SimpleFileTree {
// Remove from loaded paths so it can be reloaded when expanded again
this.loadedPaths.delete(dirPath);
} else {
// Expand
// Expand - update UI optimistically but revert on error
this.expandedDirs.add(dirPath);
if (expandBtn) expandBtn.innerHTML = '▼';
if (icon) icon.textContent = '📂';
await this.loadChildren(dirPath);
// Try to load children
const success = await this.loadChildren(dirPath);
// If loading failed, revert the UI state
if (!success) {
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);
}
}
}
@@ -252,11 +348,11 @@ class SimpleFileTree {
async loadChildren(parentPath) {
// Always reload children to ensure fresh data
console.log('Loading children for:', parentPath);
const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`);
const r = await api(`/api/heroprompt/directory?name=${currentWs}&base=${encodeURIComponent(parentPath)}&path=`);
if (r.error) {
console.warn('Failed to load directory:', parentPath, r.error);
return;
return false; // Return false to indicate failure
}
// Sort items: directories first, then files
@@ -271,7 +367,7 @@ class SimpleFileTree {
const parentElement = qs(`[data-path="${parentPath}"]`);
if (!parentElement) {
console.warn('Parent element not found for path:', parentPath);
return;
return false; // Return false to indicate failure
}
const parentDepth = parseInt(parentElement.dataset.depth || '0');
@@ -316,6 +412,7 @@ class SimpleFileTree {
});
this.loadedPaths.add(parentPath);
return true; // Return true to indicate success
}
getDepth(path) {
@@ -378,6 +475,127 @@ class SimpleFileTree {
if (tokenCountEl) tokenCountEl.textContent = tokens.toString();
}
// Select or deselect all children of a directory recursively
async selectDirectoryChildren(dirPath, select) {
// 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);
// 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) {
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 {
const response = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/list?path=${encodeURIComponent(dirPath)}`);
if (response.ok) {
const data = await response.json();
if (data.children) {
data.children.forEach(child => {
const childPath = child.path;
if (select) {
selected.add(childPath);
} else {
selected.delete(childPath);
}
});
}
} else {
console.error('Failed to fetch directory children:', response.status, response.statusText);
const errorText = await response.text();
console.error('Error response:', errorText);
}
} catch (error) {
console.error('Error selecting directory children:', error);
}
}
createFileCard(path) {
const card = document.createElement('div');
card.className = 'file-card';
@@ -395,6 +613,9 @@ class SimpleFileTree {
// Get file stats (mock data for now - could be enhanced with real file stats)
const stats = this.getFileStats(path);
// Show full path for directories to help differentiate between same-named directories
const displayPath = isDirectory ? path : path;
card.innerHTML = `
<div class="file-card-header">
<div class="file-card-icon">
@@ -402,7 +623,7 @@ class SimpleFileTree {
</div>
<div class="file-card-info">
<h4 class="file-card-name">${fileName}</h4>
<p class="file-card-path">${path}</p>
<p class="file-card-path">${displayPath}</p>
</div>
</div>
<div class="file-card-metadata">
@@ -655,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;
});
@@ -684,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();
@@ -825,6 +1074,66 @@ class SimpleFileTree {
this.updateSelectionUI();
}
async renderWorkspaceDirectories(directories) {
this.container.innerHTML = '<div class="loading">Loading workspace directories...</div>';
// Reset state
this.loadedPaths.clear();
this.expandedDirs.clear();
expandedDirs.clear();
if (!directories || directories.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Create document fragment for efficient DOM manipulation
const fragment = document.createDocumentFragment();
const elements = [];
// Create elements for each workspace directory
for (const dir of directories) {
if (!dir.path || dir.path.cat !== 'dir') continue;
const dirPath = dir.path.path;
const dirName = dir.name || dirPath.split('/').pop();
// Create a directory item that can be expanded
const item = {
name: dirName,
type: 'directory'
};
const element = this.createFileItem(item, dirPath, 0);
element.style.opacity = '0';
element.style.transform = 'translateY(-10px)';
element.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
fragment.appendChild(element);
elements.push(element);
}
// Clear container and add all elements at once
this.container.innerHTML = '';
this.container.appendChild(fragment);
// Trigger staggered animations
elements.forEach((element, i) => {
setTimeout(() => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, i * 50);
});
this.updateSelectionUI();
}
}
// Global tree instance
@@ -886,10 +1195,56 @@ async function initWorkspace() {
const sel = el('workspaceSelect');
if (sel) sel.value = currentWs;
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
// Load and display workspace directories
await loadWorkspaceDirectories();
}
async function loadWorkspaceDirectories() {
const treeEl = el('tree');
if (!treeEl) return;
try {
const children = await api(`/api/heroprompt/workspaces/${currentWs}/children`);
if (children.error) {
console.warn('Failed to load workspace children:', children.error);
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Filter only directories
const directories = children.filter(child => child.path && child.path.cat === 'dir');
if (directories.length === 0) {
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Create file tree with workspace directories as roots
if (fileTree) {
await fileTree.renderWorkspaceDirectories(directories);
}
} catch (error) {
console.error('Error loading workspace directories:', error);
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>Error loading directories</p>
<small>Please try refreshing the page</small>
</div>
`;
}
}
@@ -917,19 +1272,15 @@ async function generatePrompt() {
outputEl.innerHTML = '<div class="loading">Generating prompt...</div>';
try {
// sync selection to backend before generating
// Pass selections directly to prompt generation
const paths = Array.from(selected);
const syncResult = await post(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/selection`, {
paths: JSON.stringify(paths)
});
if (syncResult.error) {
throw new Error(`Failed to sync selection: ${syncResult.error}`);
}
const formData = new URLSearchParams();
formData.append('text', promptText);
formData.append('selected_paths', JSON.stringify(paths));
const r = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/prompt`, {
method: 'POST',
body: new URLSearchParams({ text: promptText })
body: formData
});
if (!r.ok) {
@@ -952,41 +1303,64 @@ async function copyPrompt() {
const outputEl = el('promptOutput');
if (!outputEl) {
console.warn('Prompt output element not found');
showStatus('Copy failed - element not found', 'error');
return;
}
const text = outputEl.textContent;
console.log('text', text);
if (!text || text.trim().length === 0 || text.includes('No files selected') || text.includes('Generated prompt will appear here')) {
console.warn('No valid content to copy');
// Grab the visible prompt text, stripping HTML and empty-state placeholders
const text = outputEl.innerText.trim();
if (!text || text.includes('Generated prompt will appear here') || text.includes('No files selected')) {
showStatus('Nothing to copy', 'warning');
return;
}
if (!navigator.clipboard) {
// Fallback for older browsers
fallbackCopyToClipboard(text);
return;
// Try the modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
showStatus('Prompt copied to clipboard!', 'success');
return;
} catch (e) {
console.warn('Clipboard API failed, falling back', e);
}
}
// Fallback to hidden textarea method
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed'; // avoid scrolling to bottom
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
await navigator.clipboard.writeText(text);
// Show success feedback
const originalContent = outputEl.innerHTML;
outputEl.innerHTML = '<div class="success-message">Prompt copied to clipboard!</div>';
setTimeout(() => {
outputEl.innerHTML = originalContent;
}, 2000);
const successful = document.execCommand('copy');
showStatus(successful ? 'Prompt copied!' : 'Copy failed', successful ? 'success' : 'error');
} catch (e) {
console.warn('Copy failed', e);
const originalContent = outputEl.innerHTML;
outputEl.innerHTML = '<div class="error-message">Failed to copy prompt</div>';
setTimeout(() => {
outputEl.innerHTML = originalContent;
}, 2000);
console.error('Fallback copy failed', e);
showStatus('Copy failed', 'error');
} finally {
document.body.removeChild(textarea);
}
}
/* Helper show a transient message inside the output pane */
function showStatus(msg, type = 'info') {
const out = el('promptOutput');
if (!out) return;
const original = out.innerHTML;
const statusClass = type === 'success' ? 'success-message' :
type === 'error' ? 'error-message' :
type === 'warning' ? 'warning-message' : 'info-message';
out.innerHTML = `<div class="${statusClass}">${msg}</div>`;
setTimeout(() => {
out.innerHTML = original;
}, 2000);
}
// Global fallback function for clipboard operations
function fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea');
@@ -1049,11 +1423,9 @@ async function deleteWorkspace(workspaceName) {
currentWs = names[0];
localStorage.setItem('heroprompt-current-ws', currentWs);
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
// Load directories for new current workspace
await loadWorkspaceDirectories();
}
}
@@ -1064,15 +1436,12 @@ async function deleteWorkspace(workspaceName) {
}
}
async function updateWorkspace(workspaceName, newName, newPath) {
async function updateWorkspace(workspaceName, newName) {
try {
const formData = new FormData();
if (newName && newName !== workspaceName) {
formData.append('name', newName);
}
if (newPath) {
formData.append('base_path', newPath);
}
const encodedName = encodeURIComponent(workspaceName);
const response = await fetch(`/api/heroprompt/workspaces/${encodedName}`, {
@@ -1095,12 +1464,6 @@ async function updateWorkspace(workspaceName, newName, newPath) {
}
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
return result;
} catch (e) {
console.warn('Update workspace failed', e);
@@ -1135,11 +1498,9 @@ document.addEventListener('DOMContentLoaded', function () {
workspaceSelect.addEventListener('change', async (e) => {
currentWs = e.target.value;
localStorage.setItem('heroprompt-current-ws', currentWs);
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
// Load directories for the new workspace
await loadWorkspaceDirectories();
});
}
@@ -1154,10 +1515,25 @@ document.addEventListener('DOMContentLoaded', function () {
const refreshExplorerBtn = el('refreshExplorer');
if (refreshExplorerBtn) {
refreshExplorerBtn.addEventListener('click', async () => {
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
// 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);
}
}
}
});
}
@@ -1222,11 +1598,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (wsCreateBtn) {
wsCreateBtn.addEventListener('click', () => {
const nameEl = el('wcName');
const pathEl = el('wcPath');
const errorEl = el('wcError');
if (nameEl) nameEl.value = '';
if (pathEl) pathEl.value = '';
if (errorEl) errorEl.textContent = '';
showModal('wsCreate');
@@ -1237,16 +1611,14 @@ document.addEventListener('DOMContentLoaded', function () {
if (wcCreateBtn) {
wcCreateBtn.addEventListener('click', async () => {
const name = el('wcName')?.value?.trim() || '';
const path = el('wcPath')?.value?.trim() || '';
const errorEl = el('wcError');
if (!path) {
if (errorEl) errorEl.textContent = 'Path is required.';
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const formData = { base_path: path };
if (name) formData.name = name;
const formData = { name: name };
const resp = await post('/api/heroprompt/workspaces', formData);
if (resp.error) {
@@ -1258,10 +1630,18 @@ document.addEventListener('DOMContentLoaded', function () {
localStorage.setItem('heroprompt-current-ws', currentWs);
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
// Clear the file tree since new workspace has no directories yet
if (fileTree) {
const treeEl = el('tree');
if (treeEl) {
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
}
}
hideModal('wsCreate');
@@ -1275,11 +1655,9 @@ document.addEventListener('DOMContentLoaded', function () {
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
if (info && !info.error) {
const nameEl = el('wdName');
const pathEl = el('wdPath');
const errorEl = el('wdError');
if (nameEl) nameEl.value = info.name || currentWs;
if (pathEl) pathEl.value = info.base_path || '';
if (errorEl) errorEl.textContent = '';
showModal('wsDetails');
@@ -1292,15 +1670,14 @@ document.addEventListener('DOMContentLoaded', function () {
if (wdUpdateBtn) {
wdUpdateBtn.addEventListener('click', async () => {
const name = el('wdName')?.value?.trim() || '';
const path = el('wdPath')?.value?.trim() || '';
const errorEl = el('wdError');
if (!path) {
if (errorEl) errorEl.textContent = 'Path is required.';
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const result = await updateWorkspace(currentWs, name, path);
const result = await updateWorkspace(currentWs, name);
if (result.error) {
if (errorEl) errorEl.textContent = result.error;
return;
@@ -1326,6 +1703,52 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Add Directory functionality
const addDirBtn = el('addDirBtn');
if (addDirBtn) {
addDirBtn.addEventListener('click', () => {
const pathEl = el('addDirPath');
const errorEl = el('addDirError');
if (pathEl) pathEl.value = '';
if (errorEl) errorEl.textContent = '';
showModal('addDirModal');
});
}
const addDirConfirm = el('addDirConfirm');
if (addDirConfirm) {
addDirConfirm.addEventListener('click', async () => {
const path = el('addDirPath')?.value?.trim() || '';
const errorEl = el('addDirError');
if (!path) {
if (errorEl) errorEl.textContent = 'Directory path is required.';
return;
}
// Add directory via API
const result = await post(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/dirs`, {
path: path
});
if (result.error) {
if (errorEl) errorEl.textContent = result.error;
return;
}
// Success - close modal and refresh the file tree
hideModal('addDirModal');
// Reload workspace directories to show the newly added directory
await loadWorkspaceDirectories();
// Show success message
showStatus('Directory added successfully!', 'success');
});
}
// Chat functionality
initChatInterface();
});

View File

@@ -86,6 +86,10 @@
</span>
</div>
<div class="selection-actions">
<button id="addDirBtn" class="btn btn-sm btn-primary" title="Add Directory">
<i class="icon-plus"></i>
Add Dir
</button>
</div>
</div>
</div>
@@ -310,12 +314,10 @@ Example:
</div>
<div class="modal-body">
<div class="mb-3">
<label for="wcName" class="form-label">Workspace Name (optional)</label>
<input type="text" class="form-control" id="wcName" placeholder="Enter workspace name">
</div>
<div class="mb-3">
<label for="wcPath" class="form-label">Base Path (required)</label>
<input type="text" class="form-control" id="wcPath" placeholder="Enter base directory path">
<label for="wcName" class="form-label">Workspace Name (required)</label>
<input type="text" class="form-control" id="wcName" placeholder="Enter workspace name" required>
<div class="form-text">Choose a unique name for your workspace. You can add directories to it
after creation.</div>
</div>
<div id="wcError" class="text-danger small"></div>
</div>
@@ -340,10 +342,6 @@ Example:
<label for="wdName" class="form-label">Workspace Name</label>
<input type="text" class="form-control" id="wdName">
</div>
<div class="mb-3">
<label for="wdPath" class="form-label">Base Path</label>
<input type="text" class="form-control" id="wdPath">
</div>
<div id="wdError" class="text-danger small"></div>
</div>
<div class="modal-footer">
@@ -392,6 +390,32 @@ Example:
</div>
</div>
<!-- Add Directory Modal -->
<div class="modal fade" id="addDirModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Directory to Workspace</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="addDirPath" class="form-label">Directory Path</label>
<input type="text" class="form-control" id="addDirPath"
placeholder="Enter directory path (e.g., /path/to/directory)">
<div class="form-text">Enter the full path to the directory you want to add to the workspace.
</div>
</div>
<div id="addDirError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="addDirConfirm">Add Directory</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script>