From cc837a14278b3b30853804a95d59f71359784310 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Tue, 9 Sep 2025 16:31:08 +0300 Subject: [PATCH] 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 --- .../develop/heroprompt/heroprompt_example.vsh | 62 +-- lib/develop/codewalker/tree.v | 54 ++- lib/develop/heroprompt/heroprompt_workspace.v | 199 +++++++- lib/web/ui/heroprompt_api.v | 431 ++++++++++++------ lib/web/ui/static/js/heroprompt.js | 101 +++- 5 files changed, 658 insertions(+), 189 deletions(-) diff --git a/examples/develop/heroprompt/heroprompt_example.vsh b/examples/develop/heroprompt/heroprompt_example.vsh index 904b6ce0..b862b4ad 100755 --- a/examples/develop/heroprompt/heroprompt_example.vsh +++ b/examples/develop/heroprompt/heroprompt_example.vsh @@ -9,44 +9,46 @@ import os // )! mut workspace := heroprompt.get( - name: 'example_wsx' + name: '' path: '${os.home_dir()}/code/github/freeflowuniverse/herolib' create: true )! // println('workspace (initial): ${workspace}') -// println('selected (initial): ${workspace.selected_children()}') +// workspace.delete_workspace()! +// // println('selected (initial): ${workspace.selected_children()}') +// println('workspace (initial): ${workspace}') -// Add a directory and a file -// workspace.add_dir( -// path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks' -// )! -// workspace.add_file( -// path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks/participants.md' -// )! -// println('selected (after add): ${workspace.selected_children()}') +// // Add a directory and a file +// // workspace.add_dir( +// // path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks' +// // )! +// // workspace.add_file( +// // path: '${os.home_dir()}/code/github/freeflowuniverse/docusaurus_template/example/docs/howitworks/participants.md' +// // )! +// // println('selected (after add): ${workspace.selected_children()}') -// Build a prompt from current selection (should be empty now) -mut prompt := workspace.prompt( - text: 'Using the selected files, i want you to get all print statments' -) +// // 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 ---') +// 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()}') +// // // 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}') +// // // 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}') } +// // // Optionally delete the example workspace +// // workspace.delete_workspace() or { println('delete_workspace: ${err}') } diff --git a/lib/develop/codewalker/tree.v b/lib/develop/codewalker/tree.v index c0561f65..b0dc114d 100644 --- a/lib/develop/codewalker/tree.v +++ b/lib/develop/codewalker/tree.v @@ -177,6 +177,13 @@ pub fn list_files_recursive(root string) []string { // 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) { @@ -185,29 +192,58 @@ 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 + '│ ' } - out += build_file_tree_fs_recursive(root, child_prefix) + 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 + } + + // 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 { @@ -217,6 +253,7 @@ fn build_file_tree_fs_recursive(root string, prefix string) string { } out += '${prefix}${file_connector}${os.base(f)} *\n' } + // subdirectories for j, d in dirs { sub_connector := if j == dirs.len - 1 { '└── ' } else { '├── ' } @@ -226,7 +263,20 @@ fn build_file_tree_fs_recursive(root string, prefix string) string { } else { prefix + '│ ' } - out += build_file_tree_fs_recursive(d, sub_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 + } + } else { + dir_name + } + + out += build_file_tree_fs_recursive_with_ignore(d, sub_prefix, new_rel_path, ignore_matcher) } return out } diff --git a/lib/develop/heroprompt/heroprompt_workspace.v b/lib/develop/heroprompt/heroprompt_workspace.v index 34835e51..1131ca79 100755 --- a/lib/develop/heroprompt/heroprompt_workspace.v +++ b/lib/develop/heroprompt/heroprompt_workspace.v @@ -10,7 +10,8 @@ import freeflowuniverse.herolib.develop.codewalker @[params] pub struct AddDirParams { pub mut: - path string @[required] + path string @[required] + include_tree bool = true // true for base directories, false for selected directories } @[params] @@ -45,7 +46,7 @@ pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild { exist: .yes } name: name - include_tree: true + include_tree: args.include_tree } wsp.children << ch wsp.save()! @@ -266,6 +267,54 @@ fn (wsp Workspace) build_file_content() !string { return content } +// build_file_content_for_paths generates formatted content for specific selected paths +fn (wsp Workspace) build_file_content_for_paths(selected_paths []string) !string { + mut content := '' + + for path in selected_paths { + if !os.exists(path) { + continue // Skip non-existent paths + } + + if content.len > 0 { + content += '\n\n' + } + + if os.is_file(path) { + // Add file content + content += '${path}\n' + file_content := os.read_file(path) or { + content += '(Error reading file: ${err.msg()})\n' + continue + } + ext := get_file_extension(os.base(path)) + if file_content.len == 0 { + content += '(Empty file)\n' + } else { + content += '```' + ext + '\n' + file_content + '\n```' + } + } else if os.is_dir(path) { + // Add directory content using codewalker + mut cw := codewalker.new(codewalker.CodeWalkerArgs{})! + mut fm := cw.filemap_get(path: path)! + for filepath, filecontent in fm.content { + if content.len > 0 { + content += '\n\n' + } + content += '${path}/${filepath}\n' + ext := get_file_extension(filepath) + if filecontent.len == 0 { + content += '(Empty file)\n' + } else { + content += '```' + ext + '\n' + filecontent + '\n```' + } + } + } + } + + return content +} + pub struct HeropromptTmpPrompt { pub mut: user_instructions string @@ -277,14 +326,112 @@ fn (wsp Workspace) build_user_instructions(text string) string { return text } -// build_file_map creates a complete file map with base path and metadata -fn (wsp Workspace) build_file_map() string { - mut file_map := '' +// build_file_map creates a unified tree showing the minimal path structure for all workspace items +pub fn (wsp Workspace) build_file_map() string { + // Collect all paths from workspace children + mut all_paths := []string{} for ch in wsp.children { - file_map += codewalker.build_file_tree_fs([ch.path.path], '') - file_map += '\n' + all_paths << ch.path.path } - return file_map + + if all_paths.len == 0 { + return '' + } + + // Expand directories to include all their contents + expanded_paths := expand_directory_paths(all_paths) + + // Find common root path to make the tree relative + common_root := find_common_root_path(expanded_paths) + + // Build unified tree using the selected tree function + return codewalker.build_selected_tree(expanded_paths, common_root) +} + +// find_common_root_path finds the common root directory for a list of paths +fn find_common_root_path(paths []string) string { + if paths.len == 0 { + return '' + } + if paths.len == 1 { + // For single path, use its parent directory as root + return os.dir(paths[0]) + } + + // Split all paths into components + mut path_components := [][]string{} + for path in paths { + // Normalize path and split into components + normalized := os.real_path(path) + components := normalized.split(os.path_separator).filter(it.len > 0) + path_components << components + } + + // Find common prefix + mut common_components := []string{} + if path_components.len > 0 { + // Find minimum length manually + mut min_len := path_components[0].len + for components in path_components { + if components.len < min_len { + min_len = components.len + } + } + + for i in 0 .. min_len { + component := path_components[0][i] + mut all_match := true + for j in 1 .. path_components.len { + if path_components[j][i] != component { + all_match = false + break + } + } + if all_match { + common_components << component + } else { + break + } + } + } + + // Build common root path + if common_components.len == 0 { + return os.path_separator + } + + return os.path_separator + common_components.join(os.path_separator) +} + +// expand_directory_paths expands directory paths to include all files and subdirectories +fn expand_directory_paths(paths []string) []string { + mut expanded := []string{} + + for path in paths { + if !os.exists(path) { + continue + } + + if os.is_file(path) { + // Add files directly + expanded << path + } else if os.is_dir(path) { + // Expand directories using codewalker to get all files + mut cw := codewalker.new(codewalker.CodeWalkerArgs{}) or { continue } + mut fm := cw.filemap_get(path: path) or { continue } + + // Add the directory itself + expanded << path + + // Add all files in the directory + for filepath, _ in fm.content { + full_path := os.join_path(path, filepath) + expanded << full_path + } + } + } + + return expanded } pub struct WorkspacePrompt { @@ -305,6 +452,42 @@ pub fn (wsp Workspace) prompt(args WorkspacePrompt) string { return reprompt } +@[params] +pub struct WorkspacePromptWithSelection { +pub mut: + text string + selected_paths []string +} + +// Generate prompt with specific selected paths instead of using workspace children +pub fn (wsp Workspace) prompt_with_selection(args WorkspacePromptWithSelection) !string { + user_instructions := wsp.build_user_instructions(args.text) + + // Build file map for selected paths (unified tree) + file_map := if args.selected_paths.len > 0 { + // Expand directories to include all their contents + expanded_paths := expand_directory_paths(args.selected_paths) + common_root := find_common_root_path(expanded_paths) + codewalker.build_selected_tree(expanded_paths, common_root) + } else { + // Fallback to workspace file map if no selections + wsp.build_file_map() + } + + // Build file content only for selected paths + file_contents := wsp.build_file_content_for_paths(args.selected_paths) or { + return error('failed to build file content: ${err.msg()}') + } + + prompt := HeropromptTmpPrompt{ + user_instructions: user_instructions + file_map: file_map + file_contents: file_contents + } + reprompt := $tmpl('./templates/prompt.template') + return reprompt +} + // Save the workspace fn (mut wsp Workspace) save() !&Workspace { wsp.updated = time.now() diff --git a/lib/web/ui/heroprompt_api.v b/lib/web/ui/heroprompt_api.v index 5a577f2b..37944460 100644 --- a/lib/web/ui/heroprompt_api.v +++ b/lib/web/ui/heroprompt_api.v @@ -4,14 +4,39 @@ import veb import os import json import freeflowuniverse.herolib.develop.heroprompt as hp +import freeflowuniverse.herolib.develop.codewalker + +// ============================================================================ +// Types and Structures +// ============================================================================ -// Types struct DirResp { path string items []hp.ListItem } -// Utility functions +struct SearchResult { + name string + path string + full_path string + type_ string @[json: 'type'] +} + +struct SearchResponse { + query string + results []SearchResult + count string +} + +struct RecursiveListResponse { + path string + children []map[string]string +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + fn expand_home_path(path string) string { if path.starts_with('~') { home := os.home_dir() @@ -28,20 +53,42 @@ 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 } +fn set_json_content_type(mut ctx Context) { + ctx.set_content_type('application/json') +} + +fn get_workspace_or_error(name string, mut ctx Context) ?&hp.Workspace { + wsp := hp.get(name: name, create: false) or { + set_json_content_type(mut ctx) + ctx.text(json_error('workspace not found')) + return none + } + return wsp +} + +// ============================================================================ +// Search Functionality +// ============================================================================ + +fn search_files_recursive(base_path string, query string) []SearchResult { + mut results := []SearchResult{} + query_lower := query.to_lower() + + // Create ignore matcher for consistent filtering + ignore_matcher := codewalker.gitignore_matcher_new() + + search_directory_with_ignore(base_path, base_path, query_lower, &ignore_matcher, mut + results) + return results +} + +fn search_directory_with_ignore(base_path string, current_path string, query_lower string, ignore_matcher &codewalker.IgnoreMatcher, mut results []SearchResult) { + entries := os.ls(current_path) or { return } for entry in entries { - full_path := os.join_path(dir_path, entry) + full_path := os.join_path(current_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 + // Calculate relative path for ignore checking mut rel_path := full_path if full_path.starts_with(base_path) { rel_path = full_path[base_path.len..] @@ -50,261 +97,377 @@ fn search_directory(dir_path string, base_path string, query_lower string, mut r } } + // Check if this entry should be ignored + if ignore_matcher.is_ignored(rel_path) { + continue + } + // Check if filename or path matches search query if entry.to_lower().contains(query_lower) || rel_path.to_lower().contains(query_lower) { - results << { - 'name': entry - 'path': rel_path - 'full_path': full_path - 'type': if os.is_dir(full_path) { 'directory' } else { 'file' } + results << SearchResult{ + name: entry + path: rel_path + full_path: full_path + type_: if os.is_dir(full_path) { 'directory' } else { 'file' } } } // Recursively search subdirectories if os.is_dir(full_path) { - search_directory(full_path, base_path, query_lower, mut results) + search_directory_with_ignore(base_path, full_path, query_lower, ignore_matcher, mut + results) } } } -// APIs +// ============================================================================ +// Workspace Management API Endpoints +// ============================================================================ + +// List all workspaces @['/api/heroprompt/workspaces'; get] -pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_list_workspaces(mut ctx Context) veb.Result { mut names := []string{} ws := hp.list_workspaces_fromdb() or { []&hp.Workspace{} } for w in ws { names << w.name } - ctx.set_content_type('application/json') + set_json_content_type(mut ctx) return ctx.text(json.encode(names)) } +// Create a new workspace @['/api/heroprompt/workspaces'; post] -pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_create_workspace(mut ctx Context) veb.Result { name_input := ctx.form['name'] or { '' } - // Name is now required + // Validate workspace name mut name := name_input.trim(' \t\n\r') if name.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('workspace name is required')) } - wsp := hp.get(name: name, create: true) or { return ctx.text(json_error('create failed')) } - ctx.set_content_type('application/json') + // Create workspace + wsp := hp.get(name: name, create: true) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('create failed')) + } + + set_json_content_type(mut ctx) return ctx.text(json.encode({ 'name': wsp.name })) } +// Get workspace details @['/api/heroprompt/workspaces/:name'; get] -pub fn (app &App) api_heroprompt_get(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') +pub fn (app &App) api_heroprompt_get_workspace(mut ctx Context, name string) veb.Result { + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + set_json_content_type(mut ctx) return ctx.text(json.encode({ 'name': wsp.name 'selected_files': wsp.selected_children().len.str() })) } +// Update workspace @['/api/heroprompt/workspaces/:name'; put] -pub fn (app &App) api_heroprompt_update(mut ctx Context, name string) veb.Result { - wsp := hp.get(name: name, create: false) or { - return ctx.text(json_error('workspace not found')) - } +pub fn (app &App) api_heroprompt_update_workspace(mut ctx Context, name string) veb.Result { + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } new_name := ctx.form['name'] or { name } - // Update the workspace using the update_workspace method - updated_wsp := wsp.update_workspace( - name: new_name - ) or { return ctx.text(json_error('failed to update workspace')) } + // Update the workspace + updated_wsp := wsp.update_workspace(name: new_name) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('failed to update workspace')) + } - ctx.set_content_type('application/json') + set_json_content_type(mut ctx) return ctx.text(json.encode({ 'name': updated_wsp.name })) } -// Delete endpoint using POST (VEB framework compatibility) +// Delete workspace (using POST for 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')) - } +pub fn (app &App) api_heroprompt_delete_workspace(mut ctx Context, name string) veb.Result { + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } // Delete the workspace - wsp.delete_workspace() or { return ctx.text(json_error('failed to delete workspace')) } + wsp.delete_workspace() or { + set_json_content_type(mut ctx) + return ctx.text(json_error('failed to delete workspace')) + } - ctx.set_content_type('application/json') + set_json_content_type(mut ctx) return ctx.text(json_success()) } +// ============================================================================ +// File and Directory Operations API Endpoints +// ============================================================================ + +// List directory contents @['/api/heroprompt/directory'; get] -pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_list_directory(mut ctx Context) veb.Result { wsname := ctx.query['name'] or { 'default' } path_q := ctx.query['path'] or { '' } base_path := ctx.query['base'] or { '' } if base_path.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('base path is required')) } - mut wsp := hp.get(name: wsname, create: false) or { - return ctx.text(json_error('workspace not found')) - } + wsp := get_workspace_or_error(wsname, mut ctx) or { return ctx.text('') } + items := wsp.list_dir(base_path, path_q) or { + set_json_content_type(mut ctx) return ctx.text(json_error('cannot list directory')) } - ctx.set_content_type('application/json') + + set_json_content_type(mut ctx) return ctx.text(json.encode(DirResp{ path: if path_q.len > 0 { path_q } else { base_path } items: items })) } +// Get file content @['/api/heroprompt/file'; get] -pub fn (app &App) api_heroprompt_file(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_get_file(mut ctx Context) veb.Result { wsname := ctx.query['name'] or { 'default' } path_q := ctx.query['path'] or { '' } + if path_q.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('path required')) } - // Use the path directly (should be absolute) - file_path := path_q - if !os.is_file(file_path) { + // Validate file exists and is readable + if !os.is_file(path_q) { + set_json_content_type(mut ctx) return ctx.text(json_error('not a file')) } - content := os.read_file(file_path) or { return ctx.text(json_error('failed to read')) } - ctx.set_content_type('application/json') + + content := os.read_file(path_q) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('failed to read file')) + } + + set_json_content_type(mut ctx) return ctx.text(json.encode({ - 'language': detect_lang(file_path) + 'language': detect_lang(path_q) 'content': content })) } +// Add file to workspace @['/api/heroprompt/workspaces/:name/files'; post] pub fn (app &App) api_heroprompt_add_file(mut ctx Context, name string) veb.Result { path := ctx.form['path'] or { '' } if path.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('path required')) } - mut wsp := hp.get(name: name, create: false) or { - return ctx.text(json_error('workspace not found')) + + mut wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + wsp.add_file(path: path) or { + set_json_content_type(mut ctx) + return ctx.text(json_error(err.msg())) } - wsp.add_file(path: path) or { return ctx.text(json_error(err.msg())) } + + set_json_content_type(mut ctx) return ctx.text(json_success()) } +// Add directory to workspace @['/api/heroprompt/workspaces/:name/dirs'; post] -pub fn (app &App) api_heroprompt_add_dir(mut ctx Context, name string) veb.Result { +pub fn (app &App) api_heroprompt_add_directory(mut ctx Context, name string) veb.Result { path := ctx.form['path'] or { '' } if path.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('path required')) } - mut wsp := hp.get(name: name, create: false) or { - return ctx.text(json_error('workspace not found')) + + mut wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + wsp.add_dir(path: path) or { + set_json_content_type(mut ctx) + return ctx.text(json_error(err.msg())) } - wsp.add_dir(path: path) or { return ctx.text(json_error(err.msg())) } + + set_json_content_type(mut ctx) return ctx.text(json_success()) } +// ============================================================================ +// Prompt Generation and Search API Endpoints +// ============================================================================ + +// Generate prompt from workspace selection @['/api/heroprompt/workspaces/:name/prompt'; post] pub fn (app &App) api_heroprompt_generate_prompt(mut ctx Context, name string) veb.Result { text := ctx.form['text'] or { '' } - mut wsp := hp.get(name: name, create: false) or { - ctx.set_content_type('application/json') - return ctx.text(json_error('workspace not found')) + selected_paths_json := ctx.form['selected_paths'] or { '[]' } + + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + // Parse selected paths + selected_paths := json.decode([]string, selected_paths_json) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('invalid selected paths format')) } - prompt := wsp.prompt(text: text) + + // Generate prompt with selected paths + prompt := wsp.prompt_with_selection(text: text, selected_paths: selected_paths) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('failed to generate prompt: ${err.msg()}')) + } + ctx.set_content_type('text/plain') return ctx.text(prompt) } -@['/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()) -} - +// Search files in workspace @['/api/heroprompt/workspaces/:name/search'; get] -pub fn (app &App) api_heroprompt_search(mut ctx Context, name string) veb.Result { +pub fn (app &App) api_heroprompt_search_files(mut ctx Context, name string) veb.Result { query := ctx.query['q'] or { '' } base_path := ctx.query['base'] or { '' } + // Validate input parameters if query.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('search query required')) } if base_path.len == 0 { + set_json_content_type(mut ctx) return ctx.text(json_error('base path required for search')) } - wsp := hp.get(name: name, create: false) or { - return ctx.text(json_error('workspace not found')) + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + // Perform search using improved search function + results := search_files_recursive(base_path, query) + + // Build response + response := SearchResponse{ + query: query + results: results + count: results.len.str() } - // Simple recursive file search implementation - mut results := []map[string]string{} - query_lower := query.to_lower() - - // Recursive function to search files - search_directory(base_path, 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) + set_json_content_type(mut ctx) + return ctx.text(json.encode(response)) } +// Get workspace selected children @['/api/heroprompt/workspaces/:name/children'; get] -pub fn (app &App) api_heroprompt_get_children(mut ctx Context, name string) veb.Result { - wsp := hp.get(name: name, create: false) or { - return ctx.text(json_error('workspace not found')) - } +pub fn (app &App) api_heroprompt_get_workspace_children(mut ctx Context, name string) veb.Result { + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } children := wsp.selected_children() - ctx.set_content_type('application/json') + set_json_content_type(mut ctx) return ctx.text(json.encode(children)) } + +// Get all recursive children of a directory (for directory selection) +@['/api/heroprompt/workspaces/:name/list'; get] +pub fn (app &App) api_heroprompt_list_directory_recursive(mut ctx Context, name string) veb.Result { + path_q := ctx.query['path'] or { '' } + if path_q.len == 0 { + set_json_content_type(mut ctx) + return ctx.text(json_error('path parameter is required')) + } + + wsp := get_workspace_or_error(name, mut ctx) or { return ctx.text('') } + + // Get all recursive children of the directory + children := get_recursive_directory_children(path_q) or { + set_json_content_type(mut ctx) + return ctx.text(json_error('failed to list directory: ${err.msg()}')) + } + + // Build response + response := RecursiveListResponse{ + path: path_q + children: children + } + + set_json_content_type(mut ctx) + return ctx.text(json.encode(response)) +} + +// ============================================================================ +// Directory Traversal Helper Functions +// ============================================================================ + +// Get all recursive children of a directory with proper gitignore filtering +fn get_recursive_directory_children(dir_path string) ![]map[string]string { + // Validate directory exists + if !os.exists(dir_path) { + return error('directory does not exist: ${dir_path}') + } + if !os.is_dir(dir_path) { + return error('path is not a directory: ${dir_path}') + } + + // Create ignore matcher with default patterns for consistent filtering + ignore_matcher := codewalker.gitignore_matcher_new() + + mut results := []map[string]string{} + collect_directory_children_recursive(dir_path, dir_path, &ignore_matcher, mut results) or { + return error('failed to collect directory children: ${err.msg()}') + } + return results +} + +// Recursively collect all children with proper gitignore filtering +fn collect_directory_children_recursive(base_dir string, current_dir string, ignore_matcher &codewalker.IgnoreMatcher, mut results []map[string]string) ! { + entries := os.ls(current_dir) or { return error('cannot list directory: ${current_dir}') } + + for entry in entries { + full_path := os.join_path(current_dir, entry) + + // Calculate relative path from base directory for ignore checking + rel_path := calculate_relative_path(full_path, base_dir) + + // Check if this entry should be ignored using proper gitignore logic + if ignore_matcher.is_ignored(rel_path) { + continue + } + + // Add this entry to results using full absolute path to match tree format + results << { + 'name': entry + 'path': full_path + 'type': if os.is_dir(full_path) { 'directory' } else { 'file' } + } + + // If it's a directory, recursively collect its children + if os.is_dir(full_path) { + collect_directory_children_recursive(base_dir, full_path, ignore_matcher, mut + results) or { + // Continue on error to avoid stopping the entire operation + continue + } + } + } +} + +// Calculate relative path from base directory +fn calculate_relative_path(full_path string, base_dir string) string { + mut rel_path := full_path + if full_path.starts_with(base_dir) { + rel_path = full_path[base_dir.len..] + if rel_path.starts_with('/') { + rel_path = rel_path[1..] + } + } + return rel_path +} diff --git a/lib/web/ui/static/js/heroprompt.js b/lib/web/ui/static/js/heroprompt.js index cd931fac..fd0ba136 100644 --- a/lib/web/ui/static/js/heroprompt.js +++ b/lib/web/ui/static/js/heroprompt.js @@ -141,14 +141,27 @@ 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); + // If this is a directory, also select all its children + if (item.type === 'directory') { + await this.selectDirectoryChildren(path, true); + } else { + // For files, update UI immediately since no async operation + this.updateSelectionUI(); + } } else { selected.delete(path); + // If this is a directory, also deselect all its children + if (item.type === 'directory') { + await this.selectDirectoryChildren(path, false); + } else { + // For files, update UI immediately since no async operation + this.updateSelectionUI(); + } } - this.updateSelectionUI(); }); // Expand/collapse button for directories @@ -218,11 +231,20 @@ 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 = '📁'; + } } } @@ -256,7 +278,7 @@ class SimpleFileTree { 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 +293,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 +338,7 @@ class SimpleFileTree { }); this.loadedPaths.add(parentPath); + return true; // Return true to indicate success } getDepth(path) { @@ -378,6 +401,58 @@ class SimpleFileTree { if (tokenCountEl) tokenCountEl.textContent = tokens.toString(); } + // Select or deselect all children of a directory recursively + async selectDirectoryChildren(dirPath, select) { + // First, get all children from API to update the selection state + await this.selectDirectoryChildrenFromAPI(dirPath, select); + + // Then, update any currently visible children in the DOM + this.updateVisibleCheckboxes(); + + // Update the selection UI once at the end + this.updateSelectionUI(); + } + + // 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 checkbox = item.querySelector('.tree-checkbox'); + if (checkbox && itemPath) { + // Set checkbox state based on current selection + checkbox.checked = selected.has(itemPath); + } + }); + } + + // 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'; @@ -1026,19 +1101,15 @@ async function generatePrompt() { outputEl.innerHTML = '
Generating prompt...
'; 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) {