From cc93081b157fb57a60691d44009ddbf7dec2523f Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Sun, 24 Aug 2025 12:58:09 +0300 Subject: [PATCH] feat: Add workspace selection synchronization - Create `codewalker` module with file system utilities - Refactor `Workspace` file operations to use `codewalker` - Add `include_tree` flag to `HeropromptChild` struct - Implement new `/selection` API endpoint for workspace - Sync frontend selection state to backend via new API --- lib/develop/codewalker/tree.v | 194 +++++++++++++++ lib/develop/heroprompt/heroprompt_child.v | 7 +- lib/develop/heroprompt/heroprompt_workspace.v | 232 ++++++------------ lib/web/ui/heroprompt_api.v | 32 ++- lib/web/ui/static/js/heroprompt.js | 4 + 5 files changed, 306 insertions(+), 163 deletions(-) create mode 100644 lib/develop/codewalker/tree.v diff --git a/lib/develop/codewalker/tree.v b/lib/develop/codewalker/tree.v new file mode 100644 index 00000000..cbf33c47 --- /dev/null +++ b/lib/develop/codewalker/tree.v @@ -0,0 +1,194 @@ +module codewalker + +import os + +// build_selected_tree renders a minimal tree of the given file paths. +// - files: absolute or relative file paths +// - base_root: if provided and files are absolute, the tree is rendered relative to this root +// The output marks files with a trailing " *" like the existing map convention. +pub fn build_selected_tree(files []string, base_root string) string { + mut rels := []string{} + for p in files { + mut rp := p + if base_root.len > 0 && rp.starts_with(base_root) { + rp = rp[base_root.len..] + if rp.len > 0 && rp.starts_with('/') { + rp = rp[1..] + } + } + rels << rp + } + rels.sort() + return tree_from_rel_paths(rels, '') +} + +fn tree_from_rel_paths(paths []string, prefix string) string { + mut out := '' + // group into directories and files at the current level + mut dir_children := map[string][]string{} + mut files := []string{} + for p in paths { + parts := p.split('/') + if parts.len <= 1 { + if p.len > 0 { + files << parts[0] + } + } else { + key := parts[0] + rest := parts[1..].join('/') + mut arr := dir_children[key] or { []string{} } + arr << rest + dir_children[key] = arr + } + } + mut dir_names := dir_children.keys() + dir_names.sort() + files.sort() + // render directories first, then files + for j, d in dir_names { + is_last_dir := j == dir_names.len - 1 + connector := if is_last_dir && files.len == 0 { '└── ' } else { '├── ' } + out += '${prefix}${connector}${d}\n' + child_prefix := if is_last_dir && files.len == 0 { + prefix + ' ' + } else { + prefix + '│ ' + } + out += tree_from_rel_paths(dir_children[d], child_prefix) + } + for i, f in files { + file_connector := if i == files.len - 1 { '└── ' } else { '├── ' } + out += '${prefix}${file_connector}${f} *\n' + } + return out +} + +// resolve_path resolves a relative path against a base path. +// If rel_path is absolute, returns it as-is. +// If rel_path is empty, returns base_path. +pub fn resolve_path(base_path string, rel_path string) string { + if rel_path.len == 0 { + return base_path + } + if os.is_abs_path(rel_path) { + return rel_path + } + return os.join_path(base_path, rel_path) +} + +pub struct DirItem { +pub: + name string + typ string +} + +// list_directory lists the contents of a directory. +// - base_path: workspace base path +// - rel_path: relative path from base (or absolute path) +// Returns a list of DirItem with name and type (file/directory). +pub fn list_directory(base_path string, rel_path string) ![]DirItem { + dir := resolve_path(base_path, rel_path) + if dir.len == 0 { + return error('base_path not set') + } + entries := os.ls(dir) or { return error('cannot list directory') } + mut out := []DirItem{} + for e in entries { + full := os.join_path(dir, e) + if os.is_dir(full) { + out << DirItem{ + name: e + typ: 'directory' + } + } else if os.is_file(full) { + out << DirItem{ + name: e + typ: 'file' + } + } + } + return out +} + +// list_files_recursive recursively lists all files in a directory +pub fn list_files_recursive(root string) []string { + mut out := []string{} + entries := os.ls(root) or { return out } + for e in entries { + fp := os.join_path(root, e) + if os.is_dir(fp) { + out << list_files_recursive(fp) + } else if os.is_file(fp) { + out << fp + } + } + return out +} + +// build_file_tree_fs builds a file system tree for given root directories +pub fn build_file_tree_fs(roots []string, prefix string) string { + mut out := '' + for i, root in roots { + if !os.is_dir(root) { + continue + } + 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 + } + } + dirs.sort() + files.sort() + // files + for j, f in files { + file_connector := if j == files.len - 1 && dirs.len == 0 { + '└── ' + } else { + '├── ' + } + 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) + } + } + return out +} + +// build_file_tree_selected builds a minimal tree that contains only the selected files. +// The tree is rendered relative to base_root when provided. +pub fn build_file_tree_selected(files []string, base_root string) string { + mut rels := []string{} + for fo in files { + mut rp := fo + if base_root.len > 0 && rp.starts_with(base_root) { + // make path relative to the base root + rp = rp[base_root.len..] + if rp.len > 0 && rp.starts_with('/') { + rp = rp[1..] + } + } + rels << rp + } + rels.sort() + return tree_from_rel_paths(rels, '') +} diff --git a/lib/develop/heroprompt/heroprompt_child.v b/lib/develop/heroprompt/heroprompt_child.v index d7b6466b..e57af3a7 100644 --- a/lib/develop/heroprompt/heroprompt_child.v +++ b/lib/develop/heroprompt/heroprompt_child.v @@ -5,9 +5,10 @@ import os pub struct HeropromptChild { pub mut: - content string - path pathlib.Path - name string + 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 diff --git a/lib/develop/heroprompt/heroprompt_workspace.v b/lib/develop/heroprompt/heroprompt_workspace.v index 49833b7c..a2ca62e6 100644 --- a/lib/develop/heroprompt/heroprompt_workspace.v +++ b/lib/develop/heroprompt/heroprompt_workspace.v @@ -39,12 +39,13 @@ pub fn (mut wsp Workspace) add_dir(args AddDirParams) !HeropromptChild { } mut ch := HeropromptChild{ - path: pathlib.Path{ + path: pathlib.Path{ path: abs_path cat: .dir exist: .yes } - name: name + name: name + include_tree: true } wsp.children << ch wsp.save()! @@ -221,70 +222,19 @@ pub: } pub fn (wsp &Workspace) list_dir(rel_path string) ![]ListItem { - mut dir := if rel_path.len > 0 { - if os.is_abs_path(rel_path) { - rel_path - } else { - os.join_path(wsp.base_path, rel_path) - } - } else { - wsp.base_path - } - - if dir.len == 0 { - return error('workspace base_path not set') - } - - if !os.is_abs_path(dir) { - dir = os.join_path(wsp.base_path, dir) - } - - entries := os.ls(dir) or { return error('cannot list directory') } + items := codewalker.list_directory(wsp.base_path, rel_path)! mut out := []ListItem{} - for e in entries { - full := os.join_path(dir, e) - if os.is_dir(full) { - out << ListItem{ - name: e - typ: 'directory' - } - } else if os.is_file(full) { - out << ListItem{ - name: e - typ: 'file' - } + for item in items { + out << ListItem{ + name: item.name + typ: item.typ } } return out } pub fn (wsp &Workspace) list() ![]ListItem { - mut dir := wsp.base_path - if dir.len == 0 { - return error('workspace base_path not set') - } - - if !os.is_abs_path(dir) { - dir = os.join_path(wsp.base_path, dir) - } - - entries := os.ls(dir) or { return error('cannot list directory') } - mut out := []ListItem{} - for e in entries { - full := os.join_path(dir, e) - if os.is_dir(full) { - out << ListItem{ - name: e - typ: 'directory' - } - } else if os.is_file(full) { - out << ListItem{ - name: e - typ: 'file' - } - } - } - return out + return wsp.list_dir('') } // Get the currently selected children (copy) @@ -292,21 +242,6 @@ pub fn (wsp Workspace) selected_children() []HeropromptChild { return wsp.children.clone() } -// Build utilities -fn list_files_recursive(root string) []string { - mut out := []string{} - entries := os.ls(root) or { return out } - for e in entries { - fp := os.join_path(root, e) - if os.is_dir(fp) { - out << list_files_recursive(fp) - } else if os.is_file(fp) { - out << fp - } - } - return out -} - // build_file_content generates formatted content for all selected files (and all files under selected dirs) fn (wsp Workspace) build_file_content() !string { mut content := '' @@ -333,7 +268,7 @@ fn (wsp Workspace) build_file_content() !string { } // files under selected directories, using CodeWalker for filtered traversal for ch in wsp.children { - if ch.path.cat == .dir { + if ch.path.cat == .dir && ch.include_tree { mut cw := codewalker.new(codewalker.CodeWalkerArgs{})! mut fm := cw.filemap_get(path: ch.path.path)! for rel, fc in fm.content { @@ -354,64 +289,6 @@ fn (wsp Workspace) build_file_content() !string { return content } -// Minimal tree builder for selected directories only; marks files with * -fn build_file_tree_fs(roots []HeropromptChild, prefix string) string { - mut out := '' - for i, root in roots { - if root.path.cat != .dir { - continue - } - connector := if i == roots.len - 1 { '└── ' } else { '├── ' } - out += '${prefix}${connector}${root.name}\n' - child_prefix := if i == roots.len - 1 { prefix + ' ' } else { prefix + '│ ' } - // list children under root - entries := os.ls(root.path.path) or { []string{} } - // sort: dirs first then files - mut dirs := []string{} - mut files := []string{} - for e in entries { - fp := os.join_path(root.path.path, e) - 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 += '${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([ - HeropromptChild{ - path: pathlib.Path{ - path: d - cat: .dir - exist: .yes - } - name: os.base(d) - }, - ], sub_prefix) - } - } - return out -} - pub struct HeropromptTmpPrompt { pub mut: user_instructions string @@ -430,42 +307,63 @@ fn (wsp Workspace) build_file_map() string { mut roots := []HeropromptChild{} mut files_only := []HeropromptChild{} for ch in wsp.children { - if ch.path.cat == .dir { - roots << ch + if ch.path.cat == .dir && ch.include_tree { + roots << ch // only include directories explicitly marked to include subtree } else if ch.path.cat == .file { files_only << ch } } - 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 > 0 || files_only.len > 0 { + // 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('/') + } else { + base_path + } } else { - base_path + // 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 { + os.dir(files_only[0].path.path) + } else { + '' + } } // metadata mut total_files := 0 mut total_content_length := 0 mut file_extensions := map[string]int{} - // files under dirs - for r in roots { - for f in list_files_recursive(r.path.path) { - total_files++ - ext := get_file_extension(os.base(f)) - if ext.len > 0 { - file_extensions[ext] = file_extensions[ext] + 1 + // files under dirs (only when roots present) + if roots.len > 0 { + for r in roots { + for f in codewalker.list_files_recursive(r.path.path) { + total_files++ + ext := get_file_extension(os.base(f)) + if ext.len > 0 { + file_extensions[ext] = file_extensions[ext] + 1 + } + total_content_length += (os.read_file(f) or { '' }).len } - total_content_length += (os.read_file(f) or { '' }).len } } - // files only + // standalone files for fo in files_only { total_files++ ext := get_file_extension(fo.name) if ext.len > 0 { file_extensions[ext] = file_extensions[ext] + 1 } - total_content_length += fo.content.len + // if content not loaded, read length on demand + file_len := if fo.content.len == 0 { + (os.read_file(fo.path.path) or { '' }).len + } else { + fo.content.len + } + total_content_length += file_len } mut extensions_summary := '' for ext, count in file_extensions { @@ -480,10 +378,27 @@ fn (wsp Workspace) build_file_map() string { file_map += ' | Extensions: ${extensions_summary}' } file_map += '\n\n' - file_map += build_file_tree_fs(roots, '') - // list standalone files as well - for fo in files_only { - file_map += fo.path.path + ' *\n' + // Render selected structure + if roots.len > 0 { + mut root_paths := []string{} + for r in roots { + root_paths << r.path.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' + } } } return file_map @@ -508,11 +423,10 @@ pub fn (wsp Workspace) prompt(args WorkspacePrompt) string { } // Save the workspace -fn (wsp &Workspace) save() !&Workspace { - mut tmp := wsp - tmp.updated = time.now() - tmp.is_saved = true - set(tmp)! +fn (mut wsp Workspace) save() !&Workspace { + wsp.updated = time.now() + wsp.is_saved = true + set(wsp)! return get(name: wsp.name)! } diff --git a/lib/web/ui/heroprompt_api.v b/lib/web/ui/heroprompt_api.v index 03bbcbb6..483f95bf 100644 --- a/lib/web/ui/heroprompt_api.v +++ b/lib/web/ui/heroprompt_api.v @@ -70,7 +70,7 @@ pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result { mut wsp := hp.get(name: wsname, create: false) or { return ctx.text('{"error":"workspace not found"}') } - items_w := wsp.list() or { return ctx.text('{"error":"cannot list directory"}') } + items_w := wsp.list_dir(path_q) or { return ctx.text('{"error":"cannot list directory"}') } ctx.set_content_type('application/json') mut items := []DirItem{} for it in items_w { @@ -148,3 +148,33 @@ pub fn (app &App) api_heroprompt_generate_prompt(mut ctx Context, name string) v 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('{"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('{"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('{"ok":true}') +} diff --git a/lib/web/ui/static/js/heroprompt.js b/lib/web/ui/static/js/heroprompt.js index feb53b02..8be28194 100644 --- a/lib/web/ui/static/js/heroprompt.js +++ b/lib/web/ui/static/js/heroprompt.js @@ -473,6 +473,10 @@ async function generatePrompt() { outputEl.innerHTML = '
Generating prompt...
'; try { + // sync selection to backend before generating + const paths = Array.from(selected); + await post(`/api/heroprompt/workspaces/${currentWs}/selection`, { paths: JSON.stringify(paths) }); + const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/prompt`, { method: 'POST', body: new URLSearchParams({ text: promptText })