diff --git a/lib/develop/heroprompt/heroprompt_factory_.v b/lib/develop/heroprompt/heroprompt_factory_.v index 57797d4e..b57500e4 100644 --- a/lib/develop/heroprompt/heroprompt_factory_.v +++ b/lib/develop/heroprompt/heroprompt_factory_.v @@ -23,8 +23,7 @@ pub mut: pub fn new(args ArgsGet) !&Workspace { mut obj := Workspace{ - name: args.name - base_path: args.path + name: args.name } set(obj)! return get(name: args.name)! diff --git a/lib/develop/heroprompt/heroprompt_model.v b/lib/develop/heroprompt/heroprompt_model.v index ef957903..d9d6315d 100644 --- a/lib/develop/heroprompt/heroprompt_model.v +++ b/lib/develop/heroprompt/heroprompt_model.v @@ -12,12 +12,11 @@ const default = true @[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 + name string = 'default' // Workspace name + children []HeropromptChild // List of directories and files in this workspace + created time.Time // Time of creation + updated time.Time // Time of last update + is_saved bool } // your checking & initialization code if needed @@ -41,10 +40,9 @@ pub fn heroscript_loads(heroscript string) !Workspace { 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{} + name: p.get_default('name', 'default')! + created: time.now() + updated: time.now() + children: []HeropromptChild{} } } diff --git a/lib/develop/heroprompt/heroprompt_workspace.v b/lib/develop/heroprompt/heroprompt_workspace.v index 95a6ac4b..3aafc5b2 100644 --- a/lib/develop/heroprompt/heroprompt_workspace.v +++ b/lib/develop/heroprompt/heroprompt_workspace.v @@ -33,8 +33,8 @@ pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild { name := os.base(abs_path) for child in wsp.children { - if child.name == name { - return error('another directory with the same name already exists: ${name}') + if child.path.cat == .dir && child.path.path == abs_path { + return error('the directory is already added to the workspace') } } @@ -171,12 +171,11 @@ pub mut: pub fn (wsp &Workspace) update_workspace(args UpdateParams) !&Workspace { mut updated := Workspace{ - name: if args.name.len > 0 { args.name } else { wsp.name } - base_path: if args.base_path.len > 0 { args.base_path } else { wsp.base_path } - children: wsp.children - created: wsp.created - updated: time.now() - is_saved: true + name: if args.name.len > 0 { args.name } else { wsp.name } + children: wsp.children + created: wsp.created + updated: time.now() + is_saved: true } // if name changed, delete old key first if updated.name != wsp.name { @@ -221,10 +220,10 @@ pub: typ string @[json: 'type'] } -pub fn (wsp &Workspace) list_dir(rel_path string) ![]ListItem { +pub fn (wsp &Workspace) list_dir(base_path string, rel_path string) ![]ListItem { // Create an ignore matcher with default patterns ignore_matcher := codewalker.gitignore_matcher_new() - items := codewalker.list_directory_filtered(wsp.base_path, rel_path, &ignore_matcher)! + items := codewalker.list_directory_filtered(base_path, rel_path, &ignore_matcher)! mut out := []ListItem{} for item in items { out << ListItem{ @@ -235,10 +234,6 @@ pub fn (wsp &Workspace) list_dir(rel_path string) ![]ListItem { return out } -pub fn (wsp &Workspace) list() ![]ListItem { - return wsp.list_dir('') -} - // Get the currently selected children (copy) pub fn (wsp Workspace) selected_children() []HeropromptChild { return wsp.children.clone() @@ -319,17 +314,29 @@ fn (wsp Workspace) build_file_map() string { // derive a parent path for display mut parent_path := '' if roots.len > 0 { - base_path := roots[0].path.path - parent_path = if base_path.contains('/') { - base_path.split('/')[..base_path.split('/').len - 1].join('/') + if roots.len == 1 { + // Single root - show parent directory + base_path := roots[0].path.path + parent_path = if base_path.contains('/') { + base_path.split('/')[..base_path.split('/').len - 1].join('/') + } else { + base_path + } } else { - base_path + // Multiple roots - show all root paths, comma-separated + mut root_paths := []string{} + for r in roots { + root_paths << r.path.path + } + parent_path = root_paths.join(', ') + // Truncate if too long for UI display + if parent_path.len > 100 { + parent_path = parent_path[..97] + '...' + } } } else { - // no roots; show workspace base if set, else the parent of first file - parent_path = if wsp.base_path.len > 0 { - wsp.base_path - } else if files_only.len > 0 { + // no roots; show the parent of first file if available + parent_path = if files_only.len > 0 { os.dir(files_only[0].path.path) } else { '' @@ -380,27 +387,27 @@ fn (wsp Workspace) build_file_map() string { file_map += ' | Extensions: ${extensions_summary}' } file_map += '\n\n' - // Render selected structure - if roots.len > 0 { - mut root_paths := []string{} - for r in roots { - root_paths << r.path.path + // Build a comprehensive tree that includes all files from selected directories + mut all_file_paths := []string{} + + // For each selected directory, get all files within it + for r in roots { + mut cw := codewalker.new(codewalker.CodeWalkerArgs{}) or { continue } + fm := cw.filemap_get(path: r.path.path) or { continue } + for rel_path, _ in fm.content { + abs_file_path := os.join_path(r.path.path, rel_path) + all_file_paths << abs_file_path } - file_map += codewalker.build_file_tree_fs(root_paths, '') } - // If there are only standalone selected files (no selected dirs), - // build a minimal tree via codewalker relative to the workspace base. - if files_only.len > 0 && roots.len == 0 { - mut paths := []string{} - for fo in files_only { - paths << fo.path.path - } - file_map += codewalker.build_selected_tree(paths, wsp.base_path) - } else if files_only.len > 0 && roots.len > 0 { - // Keep listing absolute paths for standalone files when directories are also selected. - for fo in files_only { - file_map += fo.path.path + ' *\n' - } + + // Add all standalone file paths + for fo in files_only { + all_file_paths << fo.path.path + } + + if all_file_paths.len > 0 { + // Build a tree that shows all files in their proper directory structure + file_map += codewalker.build_file_tree_fs(all_file_paths, '') } } return file_map diff --git a/lib/develop/heroprompt/heroprompt_workspace_test.v b/lib/develop/heroprompt/heroprompt_workspace_test.v new file mode 100644 index 00000000..516e8d27 --- /dev/null +++ b/lib/develop/heroprompt/heroprompt_workspace_test.v @@ -0,0 +1,121 @@ +module heroprompt + +import os + +fn test_multiple_dirs_same_name() ! { + // Create two temporary folders with the same basename "proj" + temp_base := os.temp_dir() + dir1 := os.join_path(temp_base, 'test_heroprompt_1', 'proj') + dir2 := os.join_path(temp_base, 'test_heroprompt_2', 'proj') + + // Ensure directories exist + os.mkdir_all(dir1)! + os.mkdir_all(dir2)! + + // Create test files in each directory + os.write_file(os.join_path(dir1, 'file1.txt'), 'content1')! + os.write_file(os.join_path(dir2, 'file2.txt'), 'content2')! + + defer { + // Cleanup + os.rmdir_all(os.join_path(temp_base, 'test_heroprompt_1')) or {} + os.rmdir_all(os.join_path(temp_base, 'test_heroprompt_2')) or {} + } + + mut ws := Workspace{ + name: 'testws' + children: []HeropromptChild{} + } + + // First dir – should succeed + child1 := ws.add_dir(path: dir1)! + assert ws.children.len == 1 + assert child1.name == 'proj' + assert child1.path.path == os.real_path(dir1) + + // Second dir – same basename, different absolute path – should also succeed + child2 := ws.add_dir(path: dir2)! + assert ws.children.len == 2 + assert child2.name == 'proj' + assert child2.path.path == os.real_path(dir2) + + // Verify both children have different absolute paths + assert child1.path.path != child2.path.path + + // Try to add the same directory again – should fail + ws.add_dir(path: dir1) or { + assert err.msg().contains('already added to the workspace') + return + } + assert false, 'Expected error when adding same directory twice' +} + +fn test_build_file_map_multiple_roots() ! { + // Create temporary directories + temp_base := os.temp_dir() + dir1 := os.join_path(temp_base, 'test_map_1', 'src') + dir2 := os.join_path(temp_base, 'test_map_2', 'src') + + os.mkdir_all(dir1)! + os.mkdir_all(dir2)! + + // Create test files + os.write_file(os.join_path(dir1, 'main.v'), 'fn main() { println("hello from dir1") }')! + os.write_file(os.join_path(dir2, 'app.v'), 'fn app() { println("hello from dir2") }')! + + defer { + os.rmdir_all(os.join_path(temp_base, 'test_map_1')) or {} + os.rmdir_all(os.join_path(temp_base, 'test_map_2')) or {} + } + + mut ws := Workspace{ + name: 'testws_map' + children: []HeropromptChild{} + } + + // Add both directories + ws.add_dir(path: dir1)! + ws.add_dir(path: dir2)! + + // Build file map + file_map := ws.build_file_map() + + // Should contain both directory paths in the parent_path + assert file_map.contains(os.real_path(dir1)) + assert file_map.contains(os.real_path(dir2)) + + // Should show correct file count (2 files total) + assert file_map.contains('Selected Files: 2') + + // Should contain both file extensions + assert file_map.contains('v(2)') +} + +fn test_single_dir_backward_compatibility() ! { + // Test that single directory workspaces still work as before + temp_base := os.temp_dir() + test_dir := os.join_path(temp_base, 'test_single', 'myproject') + + os.mkdir_all(test_dir)! + os.write_file(os.join_path(test_dir, 'main.v'), 'fn main() { println("single dir test") }')! + + defer { + os.rmdir_all(os.join_path(temp_base, 'test_single')) or {} + } + + mut ws := Workspace{ + name: 'testws_single' + children: []HeropromptChild{} + } + + // Add single directory + child := ws.add_dir(path: test_dir)! + assert ws.children.len == 1 + assert child.name == 'myproject' + + // Build file map - should work as before for single directory + file_map := ws.build_file_map() + assert file_map.contains('Selected Files: 1') + // Just check that the file map is not empty and contains some content + assert file_map.len > 0 +} diff --git a/lib/web/ui/heroprompt_api.v b/lib/web/ui/heroprompt_api.v index 871e4f12..5a577f2b 100644 --- a/lib/web/ui/heroprompt_api.v +++ b/lib/web/ui/heroprompt_api.v @@ -82,26 +82,17 @@ pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result { @['/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 + // Name is now required mut name := name_input.trim(' \t\n\r') if name.len == 0 { - name = hp.generate_random_workspace_name() + return ctx.text(json_error('workspace name is required')) } - wsp := hp.get(name: name, create: true, path: base_path) or { - return ctx.text(json_error('create failed')) - } + wsp := hp.get(name: name, create: true) 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 + 'name': wsp.name })) } @@ -113,7 +104,6 @@ pub fn (app &App) api_heroprompt_get(mut ctx Context, name string) veb.Result { 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() })) } @@ -125,19 +115,15 @@ pub fn (app &App) api_heroprompt_update(mut ctx Context, name string) veb.Result } 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 + name: new_name ) 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 + 'name': updated_wsp.name })) } @@ -159,13 +145,21 @@ pub fn (app &App) api_heroprompt_delete(mut ctx Context, name string) veb.Result pub fn (app &App) api_heroprompt_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 { + 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')) } - items := wsp.list_dir(path_q) or { return ctx.text(json_error('cannot list directory')) } + items := wsp.list_dir(base_path, 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 } + path: if path_q.len > 0 { path_q } else { base_path } items: items })) } @@ -177,15 +171,9 @@ pub fn (app &App) api_heroprompt_file(mut ctx Context) veb.Result { 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 - } + + // Use the path directly (should be absolute) + file_path := path_q if !os.is_file(file_path) { return ctx.text(json_error('not a file')) } @@ -268,10 +256,16 @@ pub fn (app &App) api_heroprompt_sync_selection(mut ctx Context, name string) ve @['/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 { '' } + base_path := ctx.query['base'] or { '' } + if query.len == 0 { return ctx.text(json_error('search query required')) } + if base_path.len == 0 { + 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')) } @@ -281,7 +275,7 @@ pub fn (app &App) api_heroprompt_search(mut ctx Context, name string) veb.Result query_lower := query.to_lower() // Recursive function to search files - search_directory(wsp.base_path, wsp.base_path, query_lower, mut results) + search_directory(base_path, base_path, query_lower, mut results) ctx.set_content_type('application/json') @@ -303,3 +297,14 @@ pub fn (app &App) api_heroprompt_search(mut ctx Context, name string) veb.Result response := '{"query":"${query}","results":${json_results},"count":"${results.len}"}' return ctx.text(response) } + +@['/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')) + } + + children := wsp.selected_children() + ctx.set_content_type('application/json') + return ctx.text(json.encode(children)) +} diff --git a/lib/web/ui/static/js/heroprompt.js b/lib/web/ui/static/js/heroprompt.js index 7cea8a88..cd931fac 100644 --- a/lib/web/ui/static/js/heroprompt.js +++ b/lib/web/ui/static/js/heroprompt.js @@ -252,7 +252,7 @@ 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); @@ -395,6 +395,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 = `
${path}
+${displayPath}