feat: integrate Heroprompt UI and backend
- Replace generic UI with dedicated Heroprompt web interface - Implement new Heroprompt-specific backend APIs - Develop client-side logic for file browsing and selection - Enhance workspace configuration and management capabilities - Remove deprecated generic UI modules and code
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
module herocmds
|
||||
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import freeflowuniverse.herolib.web.ui
|
||||
import freeflowuniverse.herolib.web.heroprompt
|
||||
import os
|
||||
import cli { Command, Flag }
|
||||
import time
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
pub fn cmd_web(mut cmdroot Command) Command {
|
||||
mut cmd_run := Command{
|
||||
name: 'web'
|
||||
description: 'Run or build the Hero UI (located in lib/web/ui).'
|
||||
description: 'Run the Heroprompt UI (located in lib/web/heroprompt).'
|
||||
required_args: 0
|
||||
execute: cmd_web_execute
|
||||
}
|
||||
@@ -56,23 +56,22 @@ fn cmd_web_execute(cmd Command) ! {
|
||||
port = 8080
|
||||
}
|
||||
|
||||
console.print_header('Starting Hero UI...')
|
||||
console.print_header('Starting Heroprompt...')
|
||||
|
||||
// Prepare arguments for the UI factory
|
||||
mut factory_args := ui.FactoryArgs{
|
||||
title: 'Hero Admin Panel'
|
||||
// Prepare arguments for the Heroprompt server
|
||||
mut factory_args := heroprompt.FactoryArgs{
|
||||
title: 'Heroprompt'
|
||||
host: host
|
||||
port: port
|
||||
open: open_
|
||||
}
|
||||
|
||||
// ---------- START WEB SERVER ----------
|
||||
console.print_header('Starting Hero UI server...')
|
||||
console.print_header('Starting Heroprompt server...')
|
||||
|
||||
// Start the UI server in a separate thread to allow for browser opening
|
||||
// Start the server in a separate thread to allow for browser opening
|
||||
spawn fn [factory_args] () {
|
||||
ui.start(factory_args) or {
|
||||
console.print_stderr('Failed to start UI server: ${err}')
|
||||
heroprompt.start(factory_args) or {
|
||||
console.print_stderr('Failed to start Heroprompt server: ${err}')
|
||||
return
|
||||
}
|
||||
}()
|
||||
@@ -81,7 +80,7 @@ fn cmd_web_execute(cmd Command) ! {
|
||||
time.sleep(2 * time.second)
|
||||
url := 'http://${factory_args.host}:${factory_args.port}'
|
||||
|
||||
console.print_green('Hero UI server is running on ${url}')
|
||||
console.print_green('Heroprompt server is running on ${url}')
|
||||
|
||||
if open_ {
|
||||
mut cmd_str := ''
|
||||
@@ -96,7 +95,7 @@ fn cmd_web_execute(cmd Command) ! {
|
||||
if cmd_str != '' {
|
||||
result := os.execute(cmd_str)
|
||||
if result.exit_code == 0 {
|
||||
console.print_green('Opened UI in default browser.')
|
||||
console.print_green('Opened Heroprompt in default browser.')
|
||||
} else {
|
||||
console.print_stderr('Failed to open browser: ${result.output}')
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ __global (
|
||||
pub struct ArgsGet {
|
||||
pub mut:
|
||||
name string = 'default'
|
||||
path string
|
||||
fromdb bool // will load from filesystem
|
||||
create bool // default will not create if not exist
|
||||
}
|
||||
@@ -23,6 +24,7 @@ pub mut:
|
||||
pub fn new(args ArgsGet) !&Workspace {
|
||||
mut obj := Workspace{
|
||||
name: args.name
|
||||
base_path: args.path
|
||||
}
|
||||
set(obj)!
|
||||
return get(name: args.name)!
|
||||
@@ -120,13 +122,28 @@ pub fn play(mut plbook PlayBook) ! {
|
||||
if !plbook.exists(filter: 'heroprompt.') {
|
||||
return
|
||||
}
|
||||
mut install_actions := plbook.find(filter: 'heroprompt.configure')!
|
||||
if install_actions.len > 0 {
|
||||
for install_action in install_actions {
|
||||
heroscript := install_action.heroscript()
|
||||
mut obj2 := heroscript_loads(heroscript)!
|
||||
set(obj2)!
|
||||
// 1) Configure workspaces
|
||||
mut cfg_actions := plbook.find(filter: 'heroprompt.configure')!
|
||||
for cfg_action in cfg_actions {
|
||||
heroscript := cfg_action.heroscript()
|
||||
mut obj := heroscript_loads(heroscript)!
|
||||
set(obj)!
|
||||
}
|
||||
// 2) Add directories
|
||||
for action in plbook.find(filter: 'heroprompt.add_dir')! {
|
||||
mut p := action.params
|
||||
wsname := p.get_default('name', heroprompt_default)!
|
||||
mut wsp := get(name: wsname)!
|
||||
path := p.get('path') or { return error("heroprompt.add_dir requires 'path'") }
|
||||
wsp.add_dir(path: path)!
|
||||
}
|
||||
// 3) Add files
|
||||
for action in plbook.find(filter: 'heroprompt.add_file')! {
|
||||
mut p := action.params
|
||||
wsname := p.get_default('name', heroprompt_default)!
|
||||
mut wsp := get(name: wsname)!
|
||||
path := p.get('path') or { return error("heroprompt.add_file requires 'path'") }
|
||||
wsp.add_file(path: path)!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module heroprompt
|
||||
|
||||
import freeflowuniverse.herolib.data.encoderhero
|
||||
import time
|
||||
import freeflowuniverse.herolib.core.playbook
|
||||
|
||||
pub const version = '0.0.0'
|
||||
const singleton = false
|
||||
@@ -28,8 +28,23 @@ fn obj_init(mycfg_ Workspace) !Workspace {
|
||||
/////////////NORMALLY NO NEED TO TOUCH
|
||||
|
||||
pub fn heroscript_loads(heroscript string) !Workspace {
|
||||
// TODO: go from heroscript to object
|
||||
//load playbook, and manually get the params out of the actions & fill in the object
|
||||
$dbg;
|
||||
return obj
|
||||
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{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,175 +6,6 @@ import os
|
||||
import freeflowuniverse.herolib.core.pathlib
|
||||
import freeflowuniverse.herolib.develop.codewalker
|
||||
|
||||
fn (wsp &Workspace) save() !&Workspace {
|
||||
mut tmp := wsp
|
||||
tmp.updated = time.now()
|
||||
tmp.is_saved = true
|
||||
set(tmp)!
|
||||
return get(name: wsp.name)!
|
||||
}
|
||||
|
||||
// // WorkspaceItem represents a file or directory in the workspace tree
|
||||
// pub struct WorkspaceItem {
|
||||
// pub mut:
|
||||
// name string // Item name (file or directory name)
|
||||
// path string // Full path to the item
|
||||
// is_directory bool // True if this is a directory
|
||||
// is_file bool // True if this is a file
|
||||
// size i64 // File size in bytes (0 for directories)
|
||||
// extension string // File extension (empty for directories)
|
||||
// children []WorkspaceItem // Child items (for directories)
|
||||
// is_expanded bool // Whether directory is expanded in UI
|
||||
// is_selected bool // Whether this item is selected for prompts
|
||||
// depth int // Depth level in the tree (0 = root)
|
||||
// }
|
||||
|
||||
// // WorkspaceList represents the complete hierarchical listing of a workspace
|
||||
// pub struct WorkspaceList {
|
||||
// pub mut:
|
||||
// root_path string // Root path of the workspace
|
||||
// items []WorkspaceItem // Top-level items in the workspace
|
||||
// total_files int // Total number of files
|
||||
// total_dirs int // Total number of directories
|
||||
// }
|
||||
|
||||
// // list returns the complete hierarchical structure of the workspace
|
||||
// pub fn (wsp Workspace) list() WorkspaceList {
|
||||
// mut result := WorkspaceList{
|
||||
// root_path: wsp.base_path
|
||||
// }
|
||||
|
||||
// if wsp.base_path.len == 0 || !os.exists(wsp.base_path) {
|
||||
// return result
|
||||
// }
|
||||
|
||||
// // Build the complete tree structure (ALL files and directories)
|
||||
// result.items = wsp.build_workspace_tree(wsp.base_path, 0)
|
||||
// wsp.calculate_totals(result.items, mut result)
|
||||
|
||||
// // Mark selected items
|
||||
// wsp.mark_selected_items(mut result.items)
|
||||
|
||||
// return result
|
||||
// }
|
||||
|
||||
// // build_workspace_tree recursively builds the workspace tree structure
|
||||
// fn (wsp Workspace) build_workspace_tree(path string, depth int) []WorkspaceItem {
|
||||
// mut items := []WorkspaceItem{}
|
||||
|
||||
// entries := os.ls(path) or { return items }
|
||||
|
||||
// for entry in entries {
|
||||
// full_path := os.join_path(path, entry)
|
||||
|
||||
// if os.is_dir(full_path) {
|
||||
// mut dir_item := WorkspaceItem{
|
||||
// name: entry
|
||||
// path: full_path
|
||||
// is_directory: true
|
||||
// is_file: false
|
||||
// size: 0
|
||||
// extension: ''
|
||||
// is_expanded: false
|
||||
// is_selected: false
|
||||
// depth: depth
|
||||
// }
|
||||
|
||||
// // Recursively get children
|
||||
// dir_item.children = wsp.build_workspace_tree(full_path, depth + 1)
|
||||
// items << dir_item
|
||||
// } else if os.is_file(full_path) {
|
||||
// file_info := os.stat(full_path) or { continue }
|
||||
// extension := get_file_extension(entry)
|
||||
|
||||
// file_item := WorkspaceItem{
|
||||
// name: entry
|
||||
// path: full_path
|
||||
// is_directory: false
|
||||
// is_file: true
|
||||
// size: file_info.size
|
||||
// extension: extension
|
||||
// children: []
|
||||
// is_expanded: false
|
||||
// is_selected: false
|
||||
// depth: depth
|
||||
// }
|
||||
// items << file_item
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Sort: directories first, then files, both alphabetically
|
||||
// items.sort_with_compare(fn (a &WorkspaceItem, b &WorkspaceItem) int {
|
||||
// if a.is_directory && !b.is_directory {
|
||||
// return -1
|
||||
// }
|
||||
// if !a.is_directory && b.is_directory {
|
||||
// return 1
|
||||
// }
|
||||
// if a.name < b.name {
|
||||
// return -1
|
||||
// }
|
||||
// if a.name > b.name {
|
||||
// return 1
|
||||
// }
|
||||
// return 0
|
||||
// })
|
||||
|
||||
// return items
|
||||
// }
|
||||
|
||||
// // calculate_totals counts total files and directories in the workspace
|
||||
// fn (wsp Workspace) calculate_totals(items []WorkspaceItem, mut result WorkspaceList) {
|
||||
// for item in items {
|
||||
// if item.is_directory {
|
||||
// result.total_dirs++
|
||||
// wsp.calculate_totals(item.children, mut result)
|
||||
// } else {
|
||||
// result.total_files++
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // mark_selected_items marks which items are currently selected for prompts
|
||||
// fn (wsp Workspace) mark_selected_items(mut items []WorkspaceItem) {
|
||||
// for mut item in items {
|
||||
// // Check if this item is selected by comparing paths
|
||||
// item.is_selected = wsp.is_item_selected(item.path)
|
||||
|
||||
// // Recursively mark children
|
||||
// if item.is_directory && item.children.len > 0 {
|
||||
// wsp.mark_selected_items(mut item.children)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // is_item_selected checks if a specific path is selected in the workspace
|
||||
// fn (wsp Workspace) is_item_selected(path string) bool {
|
||||
// dirs := wsp.children.filter(fn (item &HeropromptChild) bool {
|
||||
// return item.path.cat == .dir
|
||||
// })
|
||||
// for dir in dirs {
|
||||
// if dir.path.path == path {
|
||||
// return true
|
||||
// }
|
||||
// files := dir.children.filter(fn (item &HeropromptChild) bool {
|
||||
// return item.path.cat == .file
|
||||
// })
|
||||
// for file in files {
|
||||
// if file.path.path == path {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// child_dirs := dir.children.filter(fn (item &HeropromptChild) bool {
|
||||
// return item.path.cat == .dir
|
||||
// })
|
||||
// if wsp.is_path_in_selected_dirs(path, child_dirs) {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
// Selection API
|
||||
@[params]
|
||||
pub struct AddDirParams {
|
||||
@@ -329,6 +160,44 @@ pub fn (wsp &Workspace) delete_workspace() ! {
|
||||
delete(name: wsp.name)!
|
||||
}
|
||||
|
||||
// Update this workspace (name and/or base_path)
|
||||
@[params]
|
||||
pub struct UpdateParams {
|
||||
pub mut:
|
||||
name string
|
||||
base_path string
|
||||
}
|
||||
|
||||
pub fn (wsp &Workspace) update_workspace(args UpdateParams) !&Workspace {
|
||||
mut updated := Workspace{
|
||||
name: if args.name.len > 0 { args.name } else { wsp.name }
|
||||
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
|
||||
}
|
||||
// if name changed, delete old key first
|
||||
if updated.name != wsp.name {
|
||||
delete(name: wsp.name)!
|
||||
}
|
||||
set(updated)!
|
||||
return get(name: updated.name)!
|
||||
}
|
||||
|
||||
// @[params]
|
||||
// pub struct UpdateParams {
|
||||
// pub mut:
|
||||
// name string
|
||||
// base_path string
|
||||
// // Update only the name and the base path for now
|
||||
// }
|
||||
|
||||
// // Delete this workspace from the store
|
||||
// pub fn (wsp &Workspace) update_workspace(args_ UpdateParams) ! {
|
||||
// delete(name: wsp.name)!
|
||||
// }
|
||||
|
||||
// List workspaces (wrapper over factory list)
|
||||
pub fn list_workspaces() ![]&Workspace {
|
||||
return list(fromdb: false)!
|
||||
@@ -338,6 +207,48 @@ pub fn list_workspaces_fromdb() ![]&Workspace {
|
||||
return list(fromdb: true)!
|
||||
}
|
||||
|
||||
// List entries in a directory relative to this workspace base or absolute
|
||||
@[params]
|
||||
pub struct ListArgs {
|
||||
pub mut:
|
||||
path string // if empty, will use workspace.base_path
|
||||
}
|
||||
|
||||
pub struct ListItem {
|
||||
pub:
|
||||
name string
|
||||
typ string @[json: 'type']
|
||||
}
|
||||
|
||||
pub fn (wsp &Workspace) list() ![]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
|
||||
}
|
||||
|
||||
// Get the currently selected children (copy)
|
||||
pub fn (wsp Workspace) selected_children() []HeropromptChild {
|
||||
return wsp.children.clone()
|
||||
@@ -558,316 +469,16 @@ pub fn (wsp Workspace) prompt(args WorkspacePrompt) string {
|
||||
return reprompt
|
||||
}
|
||||
|
||||
// // is_path_in_selected_dirs recursively checks subdirectories for selected items
|
||||
// fn (wsp Workspace) is_path_in_selected_dirs(path string, dirs []&HeropromptChild) bool {
|
||||
// for dir in dirs {
|
||||
// if dir.path.cat != .dir {
|
||||
// continue
|
||||
// }
|
||||
// if dir.path.path == path {
|
||||
// return true
|
||||
// }
|
||||
// files := dir.children.filter(fn (item &HeropromptChild) bool {
|
||||
// return item.path.cat == .file
|
||||
// })
|
||||
// for file in files {
|
||||
// if file.path.path == path {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// child_dirs := dir.children.filter(fn (item &HeropromptChild) bool {
|
||||
// return item.path.cat == .dir
|
||||
// })
|
||||
// if wsp.is_path_in_selected_dirs(path, child_dirs) {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
// Save the workspace
|
||||
fn (wsp &Workspace) save() !&Workspace {
|
||||
mut tmp := wsp
|
||||
tmp.updated = time.now()
|
||||
tmp.is_saved = true
|
||||
set(tmp)!
|
||||
return get(name: wsp.name)!
|
||||
}
|
||||
|
||||
// @[params]
|
||||
// pub struct AddDirParams {
|
||||
// pub mut:
|
||||
// path string @[required]
|
||||
// select_all bool
|
||||
// }
|
||||
|
||||
// pub fn (mut wsp Workspace) add_dir(args_ AddDirParams) !&HeropromptChild {
|
||||
// if args_.path.len == 0 {
|
||||
// return error('The dir path is required')
|
||||
// }
|
||||
// if !os.exists(args_.path) {
|
||||
// return error('The provided path does not exists')
|
||||
// }
|
||||
// abs_path := os.real_path(args_.path)
|
||||
// parts := abs_path.split(os.path_separator)
|
||||
// dir_name := parts[parts.len - 1]
|
||||
// mut added := &HeropromptChild{
|
||||
// path: pathlib.Path{
|
||||
// path: abs_path
|
||||
// cat: .dir
|
||||
// exist: .yes
|
||||
// }
|
||||
// name: dir_name
|
||||
// }
|
||||
// if args_.select_all {
|
||||
// added.select_all_files_and_dirs(abs_path)
|
||||
// }
|
||||
// wsp.children << added
|
||||
// return added
|
||||
// }
|
||||
|
||||
// // Metadata structures for selected files and directories
|
||||
// struct SelectedFilesMetadata {
|
||||
// content_length int // File content length in characters
|
||||
// extension string // File extension
|
||||
// name string // File name
|
||||
// path string // Full file path
|
||||
// }
|
||||
|
||||
// struct SelectedDirsMetadata {
|
||||
// name string // Directory name
|
||||
// selected_files []SelectedFilesMetadata // Files in this directory
|
||||
// }
|
||||
|
||||
// struct WorkspaceGetSelected {
|
||||
// pub mut:
|
||||
// dirs []SelectedDirsMetadata // All directories with their selected files
|
||||
// }
|
||||
|
||||
// pub fn (wsp Workspace) get_selected() WorkspaceGetSelected {
|
||||
// mut result := WorkspaceGetSelected{}
|
||||
// for dir in wsp.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// }) {
|
||||
// mut files := []SelectedFilesMetadata{}
|
||||
// for file in dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .file
|
||||
// }) {
|
||||
// files << SelectedFilesMetadata{
|
||||
// content_length: file.content.len
|
||||
// extension: get_file_extension(file.name)
|
||||
// name: file.name
|
||||
// path: file.path.path
|
||||
// }
|
||||
// }
|
||||
// result.dirs << SelectedDirsMetadata{
|
||||
// name: dir.name
|
||||
// selected_files: files
|
||||
// }
|
||||
// }
|
||||
// return result
|
||||
// }
|
||||
|
||||
// pub struct WorkspacePrompt {
|
||||
// pub mut:
|
||||
// text string
|
||||
// }
|
||||
|
||||
// pub fn (wsp Workspace) prompt(args WorkspacePrompt) string {
|
||||
// prompt := wsp.build_prompt(args.text)
|
||||
// return prompt
|
||||
// }
|
||||
|
||||
// // Placeholder function for future needs, in case we need to highlight the user_instructions block with some addtional messages
|
||||
// fn (wsp Workspace) build_user_instructions(text string) string {
|
||||
// return text
|
||||
// }
|
||||
|
||||
// // build_file_tree creates a tree-like representation of directories and files
|
||||
// fn build_file_tree(dirs []&HeropromptChild, prefix string) string {
|
||||
// mut out := ''
|
||||
// for i, dir in dirs {
|
||||
// if dir.path.cat != .dir {
|
||||
// continue
|
||||
// }
|
||||
// // Determine the correct tree connector
|
||||
// connector := if i == dirs.len - 1 { '└── ' } else { '├── ' }
|
||||
// // Directory name
|
||||
// out += '${prefix}${connector}${dir.name}\n'
|
||||
// // Calculate new prefix for children
|
||||
// child_prefix := if i == dirs.len - 1 { prefix + ' ' } else { prefix + '│ ' }
|
||||
// // Total children (files + subdirs)
|
||||
// files := dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .file
|
||||
// })
|
||||
// subdirs := dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// })
|
||||
// total_children := files.len + subdirs.len
|
||||
// // Files in this directory
|
||||
// for j, file in files {
|
||||
// file_connector := if j == total_children - 1 { '└── ' } else { '├── ' }
|
||||
// out += '${child_prefix}${file_connector}${file.name} *\n'
|
||||
// }
|
||||
// // Recurse into subdirectories
|
||||
// for j, sub_dir in subdirs {
|
||||
// sub_connector := if files.len + j == total_children - 1 {
|
||||
// '└── '
|
||||
// } else {
|
||||
// '├── '
|
||||
// }
|
||||
// out += '${child_prefix}${sub_connector}${sub_dir.name}\n'
|
||||
// sub_prefix := if files.len + j == total_children - 1 {
|
||||
// child_prefix + ' '
|
||||
// } else {
|
||||
// child_prefix + '│ '
|
||||
// }
|
||||
// // Build content for this subdirectory directly without calling build_file_map again
|
||||
// sub_files := sub_dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .file
|
||||
// })
|
||||
// sub_subdirs := sub_dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// })
|
||||
// sub_total_children := sub_files.len + sub_subdirs.len
|
||||
// for k, sub_file in sub_files {
|
||||
// sub_file_connector := if k == sub_total_children - 1 {
|
||||
// '└── '
|
||||
// } else {
|
||||
// '├── '
|
||||
// }
|
||||
// out += '${sub_prefix}${sub_file_connector}${sub_file.name} *\n'
|
||||
// }
|
||||
// if sub_subdirs.len > 0 {
|
||||
// out += build_file_tree(sub_subdirs, sub_prefix)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return out
|
||||
// }
|
||||
|
||||
// // build_file_content generates formatted content for all selected files
|
||||
// fn (wsp Workspace) build_file_content() string {
|
||||
// mut content := ''
|
||||
|
||||
// for dir in wsp.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// }) {
|
||||
// for file in dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .file
|
||||
// }) {
|
||||
// if content.len > 0 {
|
||||
// content += '\n\n'
|
||||
// }
|
||||
// content += '${file.path.path}\n'
|
||||
// extension := get_file_extension(file.name)
|
||||
// if file.content.len == 0 {
|
||||
// content += '(Empty file)\n'
|
||||
// } else {
|
||||
// content += '```${extension}\n'
|
||||
// content += file.content
|
||||
// content += '\n```'
|
||||
// }
|
||||
// }
|
||||
// content += wsp.build_dir_file_content(dir.children)
|
||||
// }
|
||||
|
||||
// return content
|
||||
// }
|
||||
|
||||
// // build_dir_file_content recursively processes subdirectories
|
||||
// fn (wsp Workspace) build_dir_file_content(dirs []&HeropromptChild) string {
|
||||
// mut content := ''
|
||||
// for dir in dirs {
|
||||
// if dir.path.cat != .dir {
|
||||
// continue
|
||||
// }
|
||||
// for file in dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .file
|
||||
// }) {
|
||||
// if content.len > 0 {
|
||||
// content += '\n\n'
|
||||
// }
|
||||
// content += '${file.path.path}\n'
|
||||
// extension := get_file_extension(file.name)
|
||||
// if file.content.len == 0 {
|
||||
// content += '(Empty file)\n'
|
||||
// } else {
|
||||
// content += '```${extension}\n'
|
||||
// content += file.content
|
||||
// content += '\n```'
|
||||
// }
|
||||
// }
|
||||
// let_subdirs := dir.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// })
|
||||
// if let_subdirs.len > 0 {
|
||||
// content += wsp.build_dir_file_content(let_subdirs)
|
||||
// }
|
||||
// }
|
||||
// return content
|
||||
// }
|
||||
|
||||
// pub struct HeropromptTmpPrompt {
|
||||
// pub mut:
|
||||
// user_instructions string
|
||||
// file_map string
|
||||
// file_contents string
|
||||
// }
|
||||
|
||||
// // build_prompt generates the final prompt with metadata and file tree
|
||||
// fn (wsp Workspace) build_prompt(text string) string {
|
||||
// user_instructions := wsp.build_user_instructions(text)
|
||||
// file_map := wsp.build_file_map()
|
||||
// file_contents := wsp.build_file_content()
|
||||
|
||||
// prompt := HeropromptTmpPrompt{
|
||||
// user_instructions: user_instructions
|
||||
// file_map: file_map
|
||||
// file_contents: file_contents
|
||||
// }
|
||||
|
||||
// reprompt := $tmpl('./templates/prompt.template')
|
||||
// return reprompt
|
||||
// }
|
||||
|
||||
// // build_file_map creates a complete file map with base path and metadata
|
||||
// fn (wsp Workspace) build_file_map() string {
|
||||
// mut file_map := ''
|
||||
// // Consider only top-level directories as roots
|
||||
// mut roots := wsp.children.filter(fn (c &HeropromptChild) bool {
|
||||
// return c.path.cat == .dir
|
||||
// })
|
||||
// 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
|
||||
// }
|
||||
// selected_metadata := wsp.get_selected()
|
||||
// mut total_files := 0
|
||||
// mut total_content_length := 0
|
||||
// mut file_extensions := map[string]int{}
|
||||
// for dir_meta in selected_metadata.dirs {
|
||||
// total_files += dir_meta.selected_files.len
|
||||
// for file_meta in dir_meta.selected_files {
|
||||
// total_content_length += file_meta.content_length
|
||||
// if file_meta.extension.len > 0 {
|
||||
// file_extensions[file_meta.extension] = file_extensions[file_meta.extension] + 1
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// mut extensions_summary := ''
|
||||
// for ext, count in file_extensions {
|
||||
// if extensions_summary.len > 0 {
|
||||
// extensions_summary += ', '
|
||||
// }
|
||||
// extensions_summary += '${ext}(${count})'
|
||||
// }
|
||||
// file_map = '${parent_path}\n'
|
||||
// file_map += '# Selected Files: ${total_files} | Total Content: ${total_content_length} chars'
|
||||
// if extensions_summary.len > 0 {
|
||||
// file_map += ' | Extensions: ${extensions_summary}'
|
||||
// }
|
||||
// file_map += '\n\n'
|
||||
// file_map += build_file_tree(roots, '')
|
||||
// }
|
||||
// return file_map
|
||||
// }
|
||||
|
||||
/// Generate a random name for the workspace
|
||||
// Generate a random name for the workspace
|
||||
fn generate_random_workspace_name() string {
|
||||
adjectives := [
|
||||
'brave',
|
||||
|
||||
315
lib/web/heroprompt/endpoints.v
Normal file
315
lib/web/heroprompt/endpoints.v
Normal file
@@ -0,0 +1,315 @@
|
||||
module heroprompt
|
||||
|
||||
import veb
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import freeflowuniverse.herolib.develop.heroprompt as hp
|
||||
|
||||
// Types for directory listing
|
||||
struct DirItem {
|
||||
name string
|
||||
typ string @[json: 'type']
|
||||
}
|
||||
|
||||
struct DirResp {
|
||||
path string
|
||||
mut:
|
||||
items []DirItem
|
||||
}
|
||||
|
||||
// HTML routes
|
||||
@['/heroprompt'; get]
|
||||
pub fn (app &App) page_index(mut ctx Context) veb.Result {
|
||||
return ctx.html(render_index(app))
|
||||
}
|
||||
|
||||
// API routes (thin wrappers over develop.heroprompt)
|
||||
@['/api/heroprompt/workspaces'; get]
|
||||
pub fn (app &App) api_list(mut ctx Context) veb.Result {
|
||||
mut names := []string{}
|
||||
ws := hp.list(fromdb: true) 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_create(mut ctx Context) veb.Result {
|
||||
name := ctx.form['name'] or { 'default' }
|
||||
base_path_in := ctx.form['base_path'] or { '' }
|
||||
if base_path_in.len == 0 {
|
||||
return ctx.text('{"error":"base_path required"}')
|
||||
}
|
||||
mut base_path := base_path_in
|
||||
// Expand tilde to user home
|
||||
if base_path.starts_with('~') {
|
||||
home := os.home_dir()
|
||||
base_path = os.join_path(home, base_path.all_after('~'))
|
||||
}
|
||||
_ := hp.get(name: name, create: true, path: base_path) or {
|
||||
return ctx.text('{"error":"create failed"}')
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': name
|
||||
'base_path': base_path
|
||||
}))
|
||||
}
|
||||
|
||||
@['/api/heroprompt/directory'; get]
|
||||
pub fn (app &App) api_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('{"error":"workspace not found"}')
|
||||
}
|
||||
// Use workspace list method; empty path means base_path
|
||||
items_w := if path_q.len > 0 { wsp.list() or {
|
||||
return ctx.text('{"error":"cannot list directory"}')} } else { wsp.list() or {
|
||||
return ctx.text('{"error":"cannot list directory"}')} }
|
||||
ctx.set_content_type('application/json')
|
||||
mut resp := DirResp{
|
||||
path: if path_q.len > 0 { path_q } else { wsp.base_path }
|
||||
}
|
||||
for it in items_w {
|
||||
resp.items << DirItem{
|
||||
name: it.name
|
||||
typ: it.typ
|
||||
}
|
||||
}
|
||||
return ctx.text(json.encode(resp))
|
||||
}
|
||||
|
||||
// -------- File content endpoint --------
|
||||
struct FileResp {
|
||||
language string
|
||||
content string
|
||||
}
|
||||
|
||||
@['/api/heroprompt/file'; get]
|
||||
pub fn (app &App) api_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('{"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('{"error":"not a file"}')
|
||||
}
|
||||
// limit read to 1MB to avoid huge responses
|
||||
max_size := i64(1_000_000)
|
||||
sz := os.file_size(file_path)
|
||||
if sz > max_size {
|
||||
return ctx.text('{"error":"file too large"}')
|
||||
}
|
||||
content := os.read_file(file_path) or { return ctx.text('{"error":"failed to read"}') }
|
||||
lang := detect_lang(file_path)
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode(FileResp{ language: lang, content: content }))
|
||||
}
|
||||
|
||||
fn detect_lang(path string) string {
|
||||
ext := os.file_ext(path).trim_left('.')
|
||||
return match ext.to_lower() {
|
||||
'v' { 'v' }
|
||||
'js' { 'javascript' }
|
||||
'ts' { 'typescript' }
|
||||
'py' { 'python' }
|
||||
'rs' { 'rust' }
|
||||
'go' { 'go' }
|
||||
'java' { 'java' }
|
||||
'c', 'h' { 'c' }
|
||||
'cpp', 'hpp', 'cc', 'hh' { 'cpp' }
|
||||
'sh', 'bash' { 'bash' }
|
||||
'json' { 'json' }
|
||||
'yaml', 'yml' { 'yaml' }
|
||||
'html', 'htm' { 'html' }
|
||||
'css' { 'css' }
|
||||
'md' { 'markdown' }
|
||||
else { 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Filename search endpoint --------
|
||||
struct SearchItem {
|
||||
path string
|
||||
typ string @[json: 'type']
|
||||
}
|
||||
|
||||
@['/api/heroprompt/search'; get]
|
||||
pub fn (app &App) api_search(mut ctx Context) veb.Result {
|
||||
wsname := ctx.query['name'] or { 'default' }
|
||||
q := ctx.query['q'] or { '' }
|
||||
if q.len == 0 {
|
||||
return ctx.text('{"error":"q required"}')
|
||||
}
|
||||
mut base := ''
|
||||
if wsp := hp.get(name: wsname, create: false) {
|
||||
base = wsp.base_path
|
||||
}
|
||||
if base.len == 0 {
|
||||
return ctx.text('{"error":"workspace base_path not set"}')
|
||||
}
|
||||
max := (ctx.query['max'] or { '200' }).int()
|
||||
mut results := []SearchItem{}
|
||||
walk_search(base, q, max, mut results)
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode(results))
|
||||
}
|
||||
|
||||
// Workspace details
|
||||
@['/api/heroprompt/workspaces/:name'; get]
|
||||
pub fn (app &App) api_workspace_get(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': wsp.name
|
||||
'base_path': wsp.base_path
|
||||
}))
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name'; delete]
|
||||
pub fn (app &App) api_workspace_delete(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.delete_workspace() or { return ctx.text('{"error":"delete failed"}') }
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name'; patch]
|
||||
pub fn (app &App) api_workspace_patch(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
new_name := ctx.form['name'] or { '' }
|
||||
mut base_path := ctx.form['base_path'] or { '' }
|
||||
if base_path.len > 0 && base_path.starts_with('~') {
|
||||
home := os.home_dir()
|
||||
base_path = os.join_path(home, base_path.all_after('~'))
|
||||
}
|
||||
updated := wsp.update_workspace(name: new_name, base_path: base_path) or {
|
||||
return ctx.text('{"error":"update failed"}')
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': updated.name
|
||||
'base_path': updated.base_path
|
||||
}))
|
||||
}
|
||||
|
||||
// -------- Path validation endpoint --------
|
||||
struct PathValidationResp {
|
||||
is_abs bool
|
||||
exists bool
|
||||
is_dir bool
|
||||
expanded string
|
||||
}
|
||||
|
||||
@['/api/heroprompt/validate_path'; get]
|
||||
pub fn (app &App) api_validate_path(mut ctx Context) veb.Result {
|
||||
p_in := ctx.query['path'] or { '' }
|
||||
mut p := p_in
|
||||
if p.starts_with('~') {
|
||||
home := os.home_dir()
|
||||
p = os.join_path(home, p.all_after('~'))
|
||||
}
|
||||
is_abs := if p != '' { os.is_abs_path(p) } else { false }
|
||||
exists := if p != '' { os.exists(p) } else { false }
|
||||
isdir := if exists { os.is_dir(p) } else { false }
|
||||
ctx.set_content_type('application/json')
|
||||
resp := PathValidationResp{
|
||||
is_abs: is_abs
|
||||
exists: exists
|
||||
is_dir: isdir
|
||||
expanded: p
|
||||
}
|
||||
return ctx.text(json.encode(resp))
|
||||
}
|
||||
|
||||
fn walk_search(root string, q string, max int, mut out []SearchItem) {
|
||||
if out.len >= max {
|
||||
return
|
||||
}
|
||||
entries := os.ls(root) or { return }
|
||||
for e in entries {
|
||||
if e in ['.git', 'node_modules', 'build', 'dist', '.v'] {
|
||||
continue
|
||||
}
|
||||
p := os.join_path(root, e)
|
||||
if os.is_dir(p) {
|
||||
if out.len >= max {
|
||||
return
|
||||
}
|
||||
if e.to_lower().contains(q.to_lower()) {
|
||||
out << SearchItem{
|
||||
path: p
|
||||
typ: 'directory'
|
||||
}
|
||||
}
|
||||
walk_search(p, q, max, mut out)
|
||||
} else if os.is_file(p) {
|
||||
if e.to_lower().contains(q.to_lower()) {
|
||||
out << SearchItem{
|
||||
path: p
|
||||
typ: 'file'
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len >= max {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Selection and prompt endpoints --------
|
||||
@['/api/heroprompt/workspaces/:name/files'; post]
|
||||
pub fn (app &App) api_add_file(mut ctx Context, name string) veb.Result {
|
||||
path := ctx.form['path'] or { '' }
|
||||
if path.len == 0 {
|
||||
return ctx.text('{"error":"path required"}')
|
||||
}
|
||||
mut wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.add_file(path: path) or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name/dirs'; post]
|
||||
pub fn (app &App) api_add_dir(mut ctx Context, name string) veb.Result {
|
||||
path := ctx.form['path'] or { '' }
|
||||
if path.len == 0 {
|
||||
return ctx.text('{"error":"path required"}')
|
||||
}
|
||||
mut wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.add_dir(path: path) or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name/prompt'; post]
|
||||
pub fn (app &App) api_generate_prompt(mut ctx Context, name string) veb.Result {
|
||||
text := ctx.form['text'] or { '' }
|
||||
mut wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
prompt := wsp.prompt(text: text)
|
||||
ctx.set_content_type('text/plain')
|
||||
return ctx.text(prompt)
|
||||
}
|
||||
71
lib/web/heroprompt/server.v
Normal file
71
lib/web/heroprompt/server.v
Normal file
@@ -0,0 +1,71 @@
|
||||
module heroprompt
|
||||
|
||||
import veb
|
||||
import os
|
||||
|
||||
// Public Context type for veb
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
// Factory args for starting the server
|
||||
@[params]
|
||||
pub struct FactoryArgs {
|
||||
pub mut:
|
||||
host string = 'localhost'
|
||||
port int = 8090
|
||||
title string = 'Heroprompt'
|
||||
}
|
||||
|
||||
// App holds server state and config
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
title string
|
||||
port int
|
||||
base_path string // absolute path to this module directory
|
||||
}
|
||||
|
||||
// Create a new App instance (does not start the server)
|
||||
pub fn new(args FactoryArgs) !&App {
|
||||
base := os.dir(@FILE)
|
||||
mut app := App{
|
||||
title: args.title
|
||||
port: args.port
|
||||
base_path: base
|
||||
}
|
||||
// Serve static assets from this module at /static
|
||||
app.mount_static_folder_at(os.join_path(base, 'static'), '/static')!
|
||||
return &app
|
||||
}
|
||||
|
||||
// Start the webserver (blocking)
|
||||
pub fn start(args FactoryArgs) ! {
|
||||
mut app := new(args)!
|
||||
veb.run[App, Context](mut app, app.port)
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
||||
@['/'; get]
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
return ctx.html(render_index(app))
|
||||
}
|
||||
|
||||
// Rendering helpers
|
||||
fn render_index(app &App) string {
|
||||
tpl := os.join_path(app.base_path, 'templates', 'index.html')
|
||||
content := os.read_file(tpl) or { return render_index_fallback(app) }
|
||||
return render_template(content, {
|
||||
'title': app.title
|
||||
})
|
||||
}
|
||||
|
||||
fn render_index_fallback(app &App) string {
|
||||
return '<!doctype html>\n<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>'
|
||||
+ html_escape(app.title)
|
||||
+ '</title><link rel="stylesheet" href="/static/css/main.css"></head><body><div class="container"><h1>'
|
||||
+ html_escape(app.title)
|
||||
+ '</h1><p>Heroprompt server is running.</p></div><script src="/static/js/main.js"></script></body></html>'
|
||||
}
|
||||
|
||||
624
lib/web/heroprompt/static/css/main.css
Normal file
624
lib/web/heroprompt/static/css/main.css
Normal file
@@ -0,0 +1,624 @@
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--text: #e5e7eb;
|
||||
--panel: #0b1220;
|
||||
--border: #1f2937;
|
||||
--muted: #94a3b8;
|
||||
--accent: #93c5fd;
|
||||
--input: #0f172a;
|
||||
--input-border: #334155;
|
||||
--btn: #334155;
|
||||
--btn-border: #475569;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg: #f9fafb;
|
||||
--text: #0f172a;
|
||||
--panel: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--muted: #475569;
|
||||
--accent: #2563eb;
|
||||
--input: #ffffff;
|
||||
--input-border: #cbd5e1;
|
||||
--btn: #e5e7eb;
|
||||
--btn-border: #cbd5e1;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 380px) 1fr;
|
||||
gap: 16px;
|
||||
min-height: 70vh
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
border-radius: 8px
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
row-gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap
|
||||
}
|
||||
|
||||
.sidebar-header .spacer {
|
||||
display: none
|
||||
}
|
||||
|
||||
.sidebar-header select {
|
||||
min-width: 140px;
|
||||
max-width: 100%
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.sidebar-header {
|
||||
flex-wrap: nowrap
|
||||
}
|
||||
|
||||
.sidebar-header .spacer {
|
||||
display: block;
|
||||
flex: 1
|
||||
}
|
||||
}
|
||||
|
||||
.ws-info {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-left: auto
|
||||
}
|
||||
|
||||
.sidebar-header label {
|
||||
font-size: 12px;
|
||||
color: var(--muted)
|
||||
}
|
||||
|
||||
.sidebar-header select {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.sidebar-header button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
.sidebar-header #wsCreateBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
font-weight: bold
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
.prompt .actions {
|
||||
margin-top: 8px
|
||||
}
|
||||
|
||||
.prompt .actions button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
display: flex;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
.searchbar input {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.searchbar button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.sidebar-header button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.prompt {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 8px
|
||||
}
|
||||
|
||||
.prompt textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
resize: vertical
|
||||
}
|
||||
|
||||
gap: 16px
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: transparent;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.subbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 8px 0
|
||||
}
|
||||
|
||||
.subbar input {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.subbar button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.subbar input {
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.subbar button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
|
||||
/* Chat */
|
||||
.chat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 60vh
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message .bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border)
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.message.ai .bubble {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
background: var(--input);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
min-height: 60px
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
min-height: 300px
|
||||
}
|
||||
|
||||
.prompt,
|
||||
.selection {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.prompt textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prompt .actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prompt-output {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.prompt-output pre {
|
||||
white-space: pre-wrap;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Collapsible tree styles */
|
||||
#tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
#tree li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
#tree .dir {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#tree .dir .dir-label .name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#tree .file a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#tree .file a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal[aria-hidden="true"] {
|
||||
display: none
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
background: rgba(0, 0, 0, 0.5)
|
||||
}
|
||||
|
||||
.modal .row {
|
||||
display: flex;
|
||||
gap: 12px
|
||||
}
|
||||
|
||||
.modal .col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
.modal .list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow: auto
|
||||
}
|
||||
|
||||
.modal .list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--border)
|
||||
}
|
||||
|
||||
.modal .list .use {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
margin: 8vh auto;
|
||||
max-width: 520px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
z-index: 1
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px
|
||||
}
|
||||
|
||||
.modal-header .icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 18px
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
.modal-body input {
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
min-height: 16px
|
||||
}
|
||||
|
||||
|
||||
#tree .toggle {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#tree .dir-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#tree .dir .chev {
|
||||
display: inline-block;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-right: 2px solid #93c5fd;
|
||||
border-bottom: 2px solid #93c5fd;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
#tree .dir .toggle:checked+.dir-label .chev {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
#tree .children {
|
||||
display: none;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
#tree .dir .toggle:checked~.children {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#tree button {
|
||||
margin-left: 6px;
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--btn-border);
|
||||
color: var(--text);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#selected li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px
|
||||
}
|
||||
375
lib/web/heroprompt/static/js/main.js
Normal file
375
lib/web/heroprompt/static/js/main.js
Normal file
@@ -0,0 +1,375 @@
|
||||
console.log('Heroprompt UI loaded');
|
||||
|
||||
let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default';
|
||||
let selected = [];
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
async function api(url) {
|
||||
try { const r = await fetch(url); return await r.json(); }
|
||||
catch { return { error: 'request failed' }; }
|
||||
}
|
||||
async function post(url, data) {
|
||||
const form = new FormData();
|
||||
Object.entries(data).forEach(([k, v]) => form.append(k, v));
|
||||
try { const r = await fetch(url, { method: 'POST', body: form }); return await r.json(); }
|
||||
catch { return { error: 'request failed' }; }
|
||||
}
|
||||
|
||||
// Checkbox-based collapsible tree
|
||||
let nodeId = 0;
|
||||
|
||||
function renderTree(displayName, fullPath) {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'tree';
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'tree-root';
|
||||
const root = buildDirNode(displayName, fullPath, true);
|
||||
ul.appendChild(root);
|
||||
c.appendChild(ul);
|
||||
return c;
|
||||
}
|
||||
|
||||
function buildDirNode(name, fullPath, expanded = false) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'dir';
|
||||
const id = `tn_${nodeId++}`;
|
||||
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.className = 'toggle';
|
||||
toggle.id = id;
|
||||
if (expanded) toggle.checked = true;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = id;
|
||||
label.className = 'dir-label';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'chev';
|
||||
const text = document.createElement('span');
|
||||
text.className = 'name';
|
||||
text.textContent = name;
|
||||
label.appendChild(icon);
|
||||
label.appendChild(text);
|
||||
|
||||
const children = document.createElement('ul');
|
||||
children.className = 'children';
|
||||
|
||||
toggle.addEventListener('change', async () => {
|
||||
if (toggle.checked) {
|
||||
if (!li.dataset.loaded) {
|
||||
await loadChildren(fullPath, children);
|
||||
li.dataset.loaded = '1';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load immediately if expanded by default
|
||||
if (expanded) {
|
||||
setTimeout(async () => {
|
||||
await loadChildren(fullPath, children);
|
||||
li.dataset.loaded = '1';
|
||||
}, 0);
|
||||
}
|
||||
|
||||
li.appendChild(toggle);
|
||||
li.appendChild(label);
|
||||
li.appendChild(children);
|
||||
return li;
|
||||
}
|
||||
|
||||
function createFileNode(name, fullPath) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'file';
|
||||
const a = document.createElement('a');
|
||||
a.href = '#';
|
||||
a.textContent = name;
|
||||
a.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
// preview removed
|
||||
};
|
||||
const add = document.createElement('button');
|
||||
add.textContent = '+';
|
||||
add.title = 'Add to selection';
|
||||
add.onclick = () => addToSelection(fullPath);
|
||||
li.appendChild(a);
|
||||
li.appendChild(add);
|
||||
return li;
|
||||
}
|
||||
|
||||
async function loadChildren(parentPath, ul) {
|
||||
const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`);
|
||||
if (r.error) { ul.innerHTML = `<li class="err">${r.error}</li>`; return; }
|
||||
ul.innerHTML = '';
|
||||
for (const it of r.items) {
|
||||
const full = parentPath.endsWith('/') ? parentPath + it.name : parentPath + '/' + it.name;
|
||||
if (it.type === 'directory') {
|
||||
ul.appendChild(buildDirNode(it.name, full, false));
|
||||
} else {
|
||||
ul.appendChild(createFileNode(it.name, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDir(p) {
|
||||
el('tree').innerHTML = '';
|
||||
const display = p.split('/').filter(Boolean).slice(-1)[0] || p;
|
||||
el('tree').appendChild(renderTree(display, p));
|
||||
updateSelectionList();
|
||||
}
|
||||
|
||||
function updateSelectionList() {
|
||||
el('selCount').textContent = String(selected.length);
|
||||
const ul = el('selected');
|
||||
ul.innerHTML = '';
|
||||
for (const p of selected) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = p;
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'remove';
|
||||
btn.onclick = () => { selected = selected.filter(x => x !== p); updateSelectionList(); };
|
||||
li.appendChild(btn);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
// naive token estimator ~ 4 chars/token
|
||||
const tokens = Math.ceil(selected.join('\n').length / 4);
|
||||
el('tokenCount').textContent = String(Math.ceil(tokens));
|
||||
}
|
||||
|
||||
function addToSelection(p) {
|
||||
if (!selected.includes(p)) { selected.push(p); updateSelectionList(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Theme persistence and toggle
|
||||
(function initTheme() {
|
||||
const saved = localStorage.getItem('hero-theme');
|
||||
const root = document.documentElement;
|
||||
if (saved === 'light') root.classList.add('light');
|
||||
})();
|
||||
|
||||
el('toggleTheme').onclick = () => {
|
||||
const root = document.documentElement;
|
||||
const isLight = root.classList.toggle('light');
|
||||
localStorage.setItem('hero-theme', isLight ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
// Workspaces list + selector
|
||||
async function reloadWorkspaces() {
|
||||
const sel = document.getElementById('workspaceSelect');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
const names = await api('/api/heroprompt/workspaces').catch(() => []);
|
||||
for (const n of names || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n; opt.textContent = n;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// ensure current ws name exists or select first
|
||||
function updateWsInfo(info) { const box = document.getElementById('wsInfo'); if (!box) return; if (!info || info.error) { box.textContent = ''; return; } box.textContent = `${info.name} — ${info.base_path}`; }
|
||||
|
||||
if ([...sel.options].some(o => o.value === currentWs)) sel.value = currentWs;
|
||||
else if (sel.options.length > 0) sel.value = sel.options[0].value;
|
||||
}
|
||||
// On initial load: pick current or first workspace and load its base
|
||||
(async function initWorkspace() {
|
||||
const sel = document.getElementById('workspaceSelect');
|
||||
const names = await api('/api/heroprompt/workspaces').catch(() => []);
|
||||
if (!names || names.length === 0) return;
|
||||
if (!currentWs || !names.includes(currentWs)) { currentWs = names[0]; localStorage.setItem('heroprompt-current-ws', currentWs); }
|
||||
if (sel) sel.value = currentWs;
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
})();
|
||||
// Create workspace modal wiring
|
||||
const wcShow = () => { el('wcName').value = ''; el('wcPath').value = ''; el('wcError').textContent = ''; showModal('wsCreate'); };
|
||||
el('wsCreateBtn')?.addEventListener('click', wcShow);
|
||||
el('wcClose')?.addEventListener('click', () => hideModal('wsCreate'));
|
||||
el('wcCancel')?.addEventListener('click', () => hideModal('wsCreate'));
|
||||
|
||||
el('wcCreate')?.addEventListener('click', async () => {
|
||||
const name = el('wcName').value.trim();
|
||||
const path = el('wcPath').value.trim();
|
||||
if (!path) { el('wcError').textContent = 'Path is required.'; return; }
|
||||
const formData = { base_path: path };
|
||||
if (name) formData.name = name;
|
||||
const resp = await post('/api/heroprompt/workspaces', formData);
|
||||
if (resp.error) { el('wcError').textContent = resp.error; return; }
|
||||
currentWs = resp.name || currentWs;
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
await reloadWorkspaces();
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
hideModal('wsCreate');
|
||||
});
|
||||
// Workspace details modal
|
||||
el('wsDetailsBtn')?.addEventListener('click', async () => {
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
if (info && !info.error) { el('wdName').value = info.name || currentWs; el('wdPath').value = info.base_path || ''; el('wdError').textContent = ''; showModal('wsDetails'); }
|
||||
});
|
||||
|
||||
el('wdClose')?.addEventListener('click', () => hideModal('wsDetails'));
|
||||
el('wdCancel')?.addEventListener('click', () => hideModal('wsDetails'));
|
||||
|
||||
el('wdSave')?.addEventListener('click', async () => {
|
||||
const newName = el('wdName').value.trim();
|
||||
const newPath = el('wdPath').value.trim();
|
||||
// update via create semantics if name changed, or add an update endpoint later
|
||||
const form = new FormData(); if (newName) form.append('name', newName); if (newPath) form.append('base_path', newPath);
|
||||
const resp = await fetch('/api/heroprompt/workspaces', { method: 'POST', body: form });
|
||||
const j = await resp.json().catch(() => ({ error: 'request failed' }));
|
||||
if (j.error) { el('wdError').textContent = j.error; return; }
|
||||
currentWs = j.name || newName || currentWs; localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
await reloadWorkspaces();
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
hideModal('wsDetails');
|
||||
});
|
||||
|
||||
el('wdDelete')?.addEventListener('click', async () => {
|
||||
// simple delete through factory delete via dedicated endpoint would be ideal; for now we can implement a delete endpoint later
|
||||
const ok = confirm('Delete this workspace?'); if (!ok) return;
|
||||
const r = await fetch(`/api/heroprompt/workspaces/${currentWs}`, { method: 'DELETE' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
// ignore errors for now
|
||||
await reloadWorkspaces();
|
||||
const sel = document.getElementById('workspaceSelect');
|
||||
currentWs = sel?.value || '';
|
||||
localStorage.setItem('heroprompt-current-ws', currentWs);
|
||||
if (currentWs) { const info = await api(`/api/heroprompt/workspaces/${currentWs}`); const base = info?.base_path || ''; if (base) await loadDir(base); }
|
||||
hideModal('wsDetails');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
if (document.getElementById('workspaceSelect')) {
|
||||
// Copy Prompt: generate on server using workspace.prompt and copy to clipboard
|
||||
el('copyPrompt')?.addEventListener('click', async () => {
|
||||
const text = el('promptText')?.value || '';
|
||||
try {
|
||||
const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/prompt`, { method: 'POST', body: new URLSearchParams({ text }) });
|
||||
const out = await r.text();
|
||||
await navigator.clipboard.writeText(out);
|
||||
} catch (e) {
|
||||
console.warn('copy prompt failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
reloadWorkspaces();
|
||||
document.getElementById('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) await loadDir(base);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('refreshWs')?.addEventListener('click', async () => {
|
||||
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
|
||||
const base = info?.base_path || '';
|
||||
if (base) await loadDir(base);
|
||||
});
|
||||
document.getElementById('openWsManage')?.addEventListener('click', async () => {
|
||||
// populate manage list and open
|
||||
const list = el('wmList'); const err = el('wmError'); if (!list) return;
|
||||
err.textContent = ''; list.innerHTML = '';
|
||||
const names = await api('/api/heroprompt/workspaces').catch(() => []);
|
||||
for (const n of names || []) { const li = document.createElement('li'); const s = document.createElement('span'); s.textContent = n; const b = document.createElement('button'); b.className = 'use'; b.textContent = 'Use'; b.onclick = async () => { currentWs = n; await reloadWorkspaces(); const info = await api(`/api/heroprompt/workspaces/${currentWs}`); const base = info?.base_path || ''; if (base) await loadDir(base); hideModal('wsManage'); }; li.appendChild(s); li.appendChild(b); list.appendChild(li); }
|
||||
showModal('wsManage');
|
||||
});
|
||||
|
||||
// legacy setWs kept for backward compat - binds currentWs
|
||||
el('setWs')?.addEventListener('click', async () => {
|
||||
const base = el('basePath')?.value?.trim();
|
||||
if (!base) { alert('Enter base path'); return; }
|
||||
const r = await post('/api/heroprompt/workspaces', { name: currentWs, base_path: base });
|
||||
if (r.error) { alert(r.error); return; }
|
||||
await loadDir(base);
|
||||
});
|
||||
|
||||
el('doSearch').onclick = async () => {
|
||||
const q = el('search').value.trim();
|
||||
if (!q) return;
|
||||
const r = await api(`/api/heroprompt/search?name=${currentWs}&q=${encodeURIComponent(q)}`);
|
||||
if (r.error) { alert(r.error); return; }
|
||||
const tree = el('tree');
|
||||
tree.innerHTML = '<div>Search results:</div>';
|
||||
const ul = document.createElement('ul');
|
||||
for (const it of r) {
|
||||
const li = document.createElement('li');
|
||||
li.className = it.type;
|
||||
const a = document.createElement('a');
|
||||
a.href = '#'; a.textContent = it.path;
|
||||
a.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
if (it.type === 'file') {
|
||||
const rf = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(it.path)}`);
|
||||
if (!rf.error) el('preview').textContent = rf.content;
|
||||
} else {
|
||||
await loadDir(it.path);
|
||||
}
|
||||
};
|
||||
const add = document.createElement('button');
|
||||
add.textContent = '+';
|
||||
add.title = 'Add to selection';
|
||||
add.onclick = () => addToSelection(it.path);
|
||||
li.appendChild(a);
|
||||
li.appendChild(add);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
tree.appendChild(ul);
|
||||
};
|
||||
|
||||
// Tabs
|
||||
function switchTab(id) {
|
||||
for (const t of document.querySelectorAll('.tab')) t.classList.remove('active');
|
||||
for (const p of document.querySelectorAll('.tab-pane')) p.classList.remove('active');
|
||||
const btn = document.querySelector(`.tab[data-tab="${id}"]`);
|
||||
const pane = document.getElementById(`tab-${id}`);
|
||||
if (btn && pane) {
|
||||
btn.classList.add('active');
|
||||
pane.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
for (const btn of document.querySelectorAll('.tab')) {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
}
|
||||
|
||||
// Chat (client-side mock for now)
|
||||
el('sendChat').onclick = () => {
|
||||
const input = el('chatInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
addChatMessage('user', text);
|
||||
input.value = '';
|
||||
// Mock AI response
|
||||
setTimeout(() => addChatMessage('ai', 'This is a placeholder AI response.'), 500);
|
||||
};
|
||||
|
||||
function addChatMessage(role, text) {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = `message ${role}`;
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble';
|
||||
bubble.textContent = text;
|
||||
msg.appendChild(bubble);
|
||||
el('chatMessages').appendChild(msg);
|
||||
el('chatMessages').scrollTop = el('chatMessages').scrollHeight;
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function showModal(id) { const m = el(id); if (!m) return; m.setAttribute('aria-hidden', 'false'); }
|
||||
function hideModal(id) { const m = el(id); if (!m) return; m.setAttribute('aria-hidden', 'true'); el('wsError').textContent = ''; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
115
lib/web/heroprompt/templates/index.html
Normal file
115
lib/web/heroprompt/templates/index.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.title}} - Heroprompt</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="toolbar">
|
||||
<div class="title">{{.title}}</div>
|
||||
<span class="spacer"></span>
|
||||
<button id="toggleTheme" title="Toggle dark/light">🌓</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="compose">Compose</button>
|
||||
<button class="tab" data-tab="chat">Chat</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<section id="tab-compose" class="tab-pane active">
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<select id="workspaceSelect"></select>
|
||||
<button id="refreshWs">Refresh</button>
|
||||
<button id="wsCreateBtn" title="Create a new workspace">+</button>
|
||||
<button id="wsDetailsBtn" title="Workspace details">Details</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<div class="sidebar-body">
|
||||
<div class="searchbar">
|
||||
<input id="search" placeholder="Search files..." />
|
||||
<div class="ws-info" id="wsInfo"></div>
|
||||
<button id="doSearch">Search</button>
|
||||
</div>
|
||||
<div id="tree"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="prompt">
|
||||
<h3>Prompt</h3>
|
||||
<textarea id="promptText" placeholder="Type your instructions here..."></textarea>
|
||||
<div class="actions"><button id="copyPrompt">Copy Prompt</button></div>
|
||||
</div>
|
||||
<div class="selection">
|
||||
<h3>Selected Files (<span id="selCount">0</span>) — Tokens: <span id="tokenCount">0</span></h3>
|
||||
<ul id="selected"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tab-chat" class="tab-pane">
|
||||
<div class="chat">
|
||||
<div id="chatMessages" class="messages"></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chatInput" placeholder="Type a message..."></textarea>
|
||||
<button id="sendChat">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/main.js" defer></script>
|
||||
<!-- Modal: Create Workspace -->
|
||||
<div id="wsCreate" class="modal" aria-hidden="true">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h4>Create workspace</h4>
|
||||
<button id="wcClose" class="icon">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="wcName">Workspace name (optional)</label>
|
||||
<input id="wcName" placeholder="leave blank to auto-generate" />
|
||||
<small class="hint">If blank we will generate one for you</small>
|
||||
<label for="wcPath">Workspace path (required)</label>
|
||||
<input id="wcPath" placeholder="/full/path e.g. ~/code/github/project1" />
|
||||
<div id="wcError" class="error" role="alert"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="wcCancel">Cancel</button>
|
||||
<button id="wcCreate">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal: Workspace Details -->
|
||||
<div id="wsDetails" class="modal" aria-hidden="true">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h4>Workspace details</h4>
|
||||
<button id="wdClose" class="icon">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="wdName">Name</label>
|
||||
<input id="wdName" />
|
||||
<label for="wdPath">Path</label>
|
||||
<input id="wdPath" />
|
||||
<div id="wdError" class="error" role="alert"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="wdDelete">Delete</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="wdCancel">Close</button>
|
||||
<button id="wdSave">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
28
lib/web/heroprompt/utils.v
Normal file
28
lib/web/heroprompt/utils.v
Normal file
@@ -0,0 +1,28 @@
|
||||
module heroprompt
|
||||
|
||||
import strings
|
||||
|
||||
// Very small template renderer using {{.var}} replacement
|
||||
pub fn render_template(tpl string, data map[string]string) string {
|
||||
mut out := tpl
|
||||
for k, v in data {
|
||||
out = out.replace('{{.' + k + '}}', v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Minimal HTML escape
|
||||
pub fn html_escape(s string) string {
|
||||
mut b := strings.new_builder(s.len)
|
||||
for ch in s {
|
||||
match ch {
|
||||
`&` { b.write_string('&') }
|
||||
`<` { b.write_string('<') }
|
||||
`>` { b.write_string('>') }
|
||||
`"` { b.write_string('"') }
|
||||
`'` { b.write_string(''') }
|
||||
else { b.write_string(ch.str()) }
|
||||
}
|
||||
}
|
||||
return b.str()
|
||||
}
|
||||
244
lib/web/ui/endpoints.v
Normal file
244
lib/web/ui/endpoints.v
Normal file
@@ -0,0 +1,244 @@
|
||||
module ui
|
||||
|
||||
import veb
|
||||
import freeflowuniverse.herolib.develop.heroprompt
|
||||
import os
|
||||
import json
|
||||
|
||||
// Directory browsing and file read endpoints for Heroprompt.js compatibility
|
||||
struct DirItem {
|
||||
name string
|
||||
typ string @[json: 'type']
|
||||
}
|
||||
|
||||
struct DirResp {
|
||||
path string
|
||||
mut:
|
||||
items []DirItem
|
||||
}
|
||||
|
||||
@['/api/heroprompt/directory'; get]
|
||||
pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result {
|
||||
// Optional workspace name, defaults to 'default'
|
||||
wsname := ctx.query['name'] or { 'default' }
|
||||
path_q := ctx.query['path'] or { '' }
|
||||
if path_q.len == 0 {
|
||||
return ctx.text('{"error":"path required"}')
|
||||
}
|
||||
// Try to resolve against workspace base_path if available, but do not require it
|
||||
mut base := ''
|
||||
if wsp := heroprompt.get(name: wsname, create: false) {
|
||||
base = wsp.base_path
|
||||
}
|
||||
// Resolve path: if absolute, use as-is; else join with base
|
||||
mut dir_path := path_q
|
||||
if !os.is_abs_path(dir_path) && base.len > 0 {
|
||||
dir_path = os.join_path(base, dir_path)
|
||||
}
|
||||
// List entries
|
||||
entries := os.ls(dir_path) or { return ctx.text('{"error":"cannot list directory"}') }
|
||||
mut items := []map[string]string{}
|
||||
for e in entries {
|
||||
full := os.join_path(dir_path, e)
|
||||
if os.is_dir(full) {
|
||||
items << {
|
||||
'name': e
|
||||
'type': 'directory'
|
||||
}
|
||||
} else if os.is_file(full) {
|
||||
items << {
|
||||
'name': e
|
||||
'type': 'file'
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
// Encode strongly typed JSON response
|
||||
mut resp := DirResp{
|
||||
path: dir_path
|
||||
}
|
||||
for it in items {
|
||||
resp.items << DirItem{
|
||||
name: it['name'] or { '' }
|
||||
typ: it['type'] or { '' }
|
||||
}
|
||||
}
|
||||
return ctx.text(json.encode(resp))
|
||||
}
|
||||
|
||||
@['/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('{"error":"path required"}')
|
||||
}
|
||||
// Try to resolve against workspace base_path if available, but do not require it
|
||||
mut base := ''
|
||||
if wsp := heroprompt.get(name: wsname, create: false) {
|
||||
base = wsp.base_path
|
||||
}
|
||||
mut file_path := path_q
|
||||
if !os.is_abs_path(file_path) && base.len > 0 {
|
||||
file_path = os.join_path(base, file_path)
|
||||
}
|
||||
content := os.read_file(file_path) or { return ctx.text('{"error":"failed to read"}') }
|
||||
lang := detect_lang(file_path)
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'language': lang
|
||||
'content': content
|
||||
}))
|
||||
}
|
||||
|
||||
fn detect_lang(path string) string {
|
||||
ext := os.file_ext(path).trim_left('.')
|
||||
return match ext.to_lower() {
|
||||
'v' { 'v' }
|
||||
'js' { 'javascript' }
|
||||
'ts' { 'typescript' }
|
||||
'py' { 'python' }
|
||||
'rs' { 'rust' }
|
||||
'go' { 'go' }
|
||||
'java' { 'java' }
|
||||
'c', 'h' { 'c' }
|
||||
'cpp', 'hpp', 'cc', 'hh' { 'cpp' }
|
||||
'sh', 'bash' { 'bash' }
|
||||
'json' { 'json' }
|
||||
'yaml', 'yml' { 'yaml' }
|
||||
'html', 'htm' { 'html' }
|
||||
'css' { 'css' }
|
||||
'md' { 'markdown' }
|
||||
else { 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
// Heroprompt API: list workspaces
|
||||
@['/api/heroprompt/workspaces'; get]
|
||||
pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result {
|
||||
mut names := []string{}
|
||||
ws := heroprompt.list(fromdb: true) or { []&heroprompt.Workspace{} }
|
||||
for w in ws {
|
||||
names << w.name
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode(names))
|
||||
}
|
||||
|
||||
// Heroprompt API: create/get workspace
|
||||
@['/api/heroprompt/workspaces'; post]
|
||||
pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result {
|
||||
name := ctx.form['name'] or { '' }
|
||||
base_path := ctx.form['base_path'] or { '' }
|
||||
|
||||
if base_path.len == 0 {
|
||||
return ctx.text('{"error":"base_path required"}')
|
||||
}
|
||||
|
||||
mut wsp := heroprompt.get(name: name, create: true, path: base_path) or {
|
||||
return ctx.text('{"error":"create failed"}')
|
||||
}
|
||||
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': name
|
||||
'base_path': base_path
|
||||
}))
|
||||
}
|
||||
|
||||
// Heroprompt API: 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 {
|
||||
path := ctx.form['path'] or { '' }
|
||||
if path.len == 0 {
|
||||
return ctx.text('{"error":"path required"}')
|
||||
}
|
||||
mut wsp := heroprompt.get(name: name, create: true) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.add_dir(path: path) or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
// Heroprompt API: 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 {
|
||||
return ctx.text('{"error":"path required"}')
|
||||
}
|
||||
mut wsp := heroprompt.get(name: name, create: true) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.add_file(path: path) or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
// Heroprompt API: generate prompt
|
||||
@['/api/heroprompt/workspaces/:name/prompt'; post]
|
||||
pub fn (app &App) api_heroprompt_prompt(mut ctx Context, name string) veb.Result {
|
||||
text := ctx.form['text'] or { '' }
|
||||
mut wsp := heroprompt.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
prompt := wsp.prompt(text: text)
|
||||
ctx.set_content_type('text/plain')
|
||||
return ctx.text(prompt)
|
||||
}
|
||||
|
||||
// Heroprompt API: get workspace details
|
||||
@['/api/heroprompt/workspaces/:name'; get]
|
||||
pub fn (app &App) api_heroprompt_get(mut ctx Context, name string) veb.Result {
|
||||
wsp := heroprompt.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
mut children := []map[string]string{}
|
||||
for ch in wsp.children {
|
||||
children << {
|
||||
'name': ch.name
|
||||
'path': ch.path.path
|
||||
'type': if ch.path.cat == .dir { 'directory' } else { 'file' }
|
||||
}
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': wsp.name
|
||||
'base_path': wsp.base_path
|
||||
'children': json.encode(children)
|
||||
}))
|
||||
}
|
||||
|
||||
// Heroprompt API: delete workspace
|
||||
@['/api/heroprompt/workspaces/:name'; delete]
|
||||
pub fn (app &App) api_heroprompt_delete(mut ctx Context, name string) veb.Result {
|
||||
wsp := heroprompt.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.delete_workspace() or { return ctx.text('{"error":"delete failed"}') }
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
// Heroprompt API: remove directory
|
||||
@['/api/heroprompt/workspaces/:name/dirs/remove'; post]
|
||||
pub fn (app &App) api_heroprompt_remove_dir(mut ctx Context, name string) veb.Result {
|
||||
path := ctx.form['path'] or { '' }
|
||||
mut wsp := heroprompt.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.remove_dir(path: path, name: '') or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
// Heroprompt API: remove file
|
||||
@['/api/heroprompt/workspaces/:name/files/remove'; post]
|
||||
pub fn (app &App) api_heroprompt_remove_file(mut ctx Context, name string) veb.Result {
|
||||
path := ctx.form['path'] or { '' }
|
||||
mut wsp := heroprompt.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
wsp.remove_file(path: path, name: '') or { return ctx.text('{"error":"' + err.msg() + '"}') }
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
module ui
|
||||
|
||||
import veb
|
||||
import os
|
||||
import net.http
|
||||
|
||||
|
||||
// Public Context type for veb
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
// Simple tree menu structure
|
||||
pub struct MenuItem {
|
||||
pub:
|
||||
title string
|
||||
href string
|
||||
children []MenuItem
|
||||
}
|
||||
|
||||
// Factory args
|
||||
@[params]
|
||||
pub struct FactoryArgs {
|
||||
pub mut:
|
||||
name string = 'default'
|
||||
host string = 'localhost'
|
||||
port int = 8080
|
||||
title string = 'Admin'
|
||||
menu []MenuItem
|
||||
open bool
|
||||
}
|
||||
|
||||
// The App holds server state and config
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
title string
|
||||
menu []MenuItem
|
||||
port int
|
||||
}
|
||||
|
||||
|
||||
// Start the webserver (blocking)
|
||||
pub fn start(args FactoryArgs) ! {
|
||||
mut app := new(args)!
|
||||
veb.run[App, Context](mut app, app.port)
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
||||
// Redirect root to /admin
|
||||
@['/'; get]
|
||||
pub fn (app &App) root(mut ctx Context) veb.Result {
|
||||
return ctx.redirect('/admin')
|
||||
}
|
||||
|
||||
// Admin home page
|
||||
@['/admin'; get]
|
||||
pub fn (app &App) admin_index(mut ctx Context) veb.Result {
|
||||
return ctx.html(app.render_admin('/', 'Welcome'))
|
||||
}
|
||||
|
||||
// HeroScript editor page
|
||||
@['/admin/heroscript'; get]
|
||||
pub fn (app &App) admin_heroscript(mut ctx Context) veb.Result {
|
||||
return ctx.html(app.render_heroscript())
|
||||
}
|
||||
|
||||
// Chat page
|
||||
@['/admin/chat'; get]
|
||||
pub fn (app &App) admin_chat(mut ctx Context) veb.Result {
|
||||
return ctx.html(app.render_chat())
|
||||
}
|
||||
|
||||
// Static CSS files
|
||||
@['/static/css/colors.css'; get]
|
||||
pub fn (app &App) serve_colors_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'colors.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
@['/static/css/main.css'; get]
|
||||
pub fn (app &App) serve_main_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'main.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
// Static JS files
|
||||
@['/static/js/theme.js'; get]
|
||||
pub fn (app &App) serve_theme_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'theme.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/js/heroscript.js'; get]
|
||||
pub fn (app &App) serve_heroscript_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'heroscript.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/js/chat.js'; get]
|
||||
pub fn (app &App) serve_chat_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'chat.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/css/heroscript.css'; get]
|
||||
pub fn (app &App) serve_heroscript_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'heroscript.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
@['/static/css/chat.css'; get]
|
||||
pub fn (app &App) serve_chat_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'chat.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
// Catch-all content under /admin/*
|
||||
@['/admin/:path...'; get]
|
||||
pub fn (app &App) admin_section(mut ctx Context, path string) veb.Result {
|
||||
// Render current path in the main content
|
||||
return ctx.html(app.render_admin(path, 'Content'))
|
||||
}
|
||||
|
||||
// View rendering using external template
|
||||
|
||||
fn (app &App) render_admin(path string, heading string) string {
|
||||
// Get the template file path relative to the module
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'admin_layout.html')
|
||||
|
||||
// Read the template file
|
||||
template_content := os.read_file(template_path) or {
|
||||
// Fallback to inline template if file not found
|
||||
return app.render_admin_fallback(path, heading)
|
||||
}
|
||||
|
||||
// Generate menu HTML
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
|
||||
// Simple template variable replacement
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.heading}}', heading)
|
||||
result = result.replace('{{.path}}', path)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HeroScript editor rendering using external template
|
||||
fn (app &App) render_heroscript() string {
|
||||
// Get the template file path relative to the module
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'heroscript_editor.html')
|
||||
|
||||
// Read the template file
|
||||
template_content := os.read_file(template_path) or {
|
||||
// Fallback to basic template if file not found
|
||||
return app.render_heroscript_fallback()
|
||||
}
|
||||
|
||||
// Generate menu HTML
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
|
||||
// Simple template variable replacement
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.css_heroscript_url}}', '/static/css/heroscript.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
result = result.replace('{{.js_heroscript_url}}', '/static/js/heroscript.js')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Chat rendering using external template
|
||||
fn (app &App) render_chat() string {
|
||||
// Get the template file path relative to the module
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'chat.html')
|
||||
|
||||
// Read the template file
|
||||
template_content := os.read_file(template_path) or {
|
||||
// Fallback to basic template if file not found
|
||||
return app.render_chat_fallback()
|
||||
}
|
||||
|
||||
// Generate menu HTML
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
|
||||
// Simple template variable replacement
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.css_chat_url}}', '/static/css/chat.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
result = result.replace('{{.js_chat_url}}', '/static/js/chat.js')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback HeroScript rendering method
|
||||
fn (app &App) render_heroscript_fallback() string {
|
||||
return '
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${app.title} - HeroScript Editor</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1>HeroScript Editor</h1>
|
||||
<p>HeroScript editor template not found. Please check the template files.</p>
|
||||
<a href="/admin" class="btn btn-primary">Back to Admin</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'
|
||||
}
|
||||
|
||||
// Fallback Chat rendering method
|
||||
fn (app &App) render_chat_fallback() string {
|
||||
return '
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${app.title} - Chat</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1>Chat Assistant</h1>
|
||||
<p>Chat template not found. Please check the template files.</p>
|
||||
<a href="/admin" class="btn btn-primary">Back to Admin</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'
|
||||
}
|
||||
|
||||
// Fallback rendering method (inline template)
|
||||
fn (app &App) render_admin_fallback(path string, heading string) string {
|
||||
return '
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${app.title}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<style>
|
||||
body { padding-top: 44px; }
|
||||
.header {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 44px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 260px;
|
||||
overflow-y: auto;
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
}
|
||||
.main {
|
||||
margin-left: 260px;
|
||||
padding: 16px;
|
||||
}
|
||||
.list-group-item {
|
||||
border: 0;
|
||||
padding: .35rem .75rem;
|
||||
background: transparent;
|
||||
}
|
||||
.menu-leaf a {
|
||||
color: #212529;
|
||||
text-decoration: none;
|
||||
}
|
||||
.menu-toggle {
|
||||
text-decoration: none;
|
||||
color: #212529;
|
||||
}
|
||||
.menu-toggle .chev {
|
||||
font-size: 10px;
|
||||
opacity: .6;
|
||||
}
|
||||
.menu-section {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-dark fixed-top header px-2">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between">
|
||||
<div class="text-white fw-bold">${app.title}</div>
|
||||
<div class="text-white-50">Admin</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="p-2">
|
||||
<div class="menu-section">Navigation</div>
|
||||
<div class="list-group list-group-flush">
|
||||
${menu_html(app.menu,
|
||||
0, 'm')}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h5 class="mb-0">${heading}</h5>
|
||||
<span class="ms-2 text-muted small">/admin/${path}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p class="text-muted">This is a placeholder admin content area for: <code>/admin/${path}</code>.</p>
|
||||
<p class="mb-0">Use the treeview on the left to navigate.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
'
|
||||
}
|
||||
|
||||
// Recursive menu renderer
|
||||
|
||||
fn menu_html(items []MenuItem, depth int, prefix string) string {
|
||||
mut out := []string{}
|
||||
for i, it in items {
|
||||
id := '${prefix}_${depth}_${i}'
|
||||
if it.children.len > 0 {
|
||||
// expandable group
|
||||
out << '<div class="list-group-item">'
|
||||
out << '<a class="menu-toggle d-flex align-items-center justify-content-between" data-bs-toggle="collapse" href="#${id}" role="button" aria-expanded="${if depth == 0 {
|
||||
'true'
|
||||
} else {
|
||||
'false'
|
||||
}}" aria-controls="${id}">'
|
||||
out << '<span>${it.title}</span><span class="chev">›</span>'
|
||||
out << '</a>'
|
||||
out << '<div class="collapse ${if depth == 0 { 'show' } else { '' }}" id="${id}">'
|
||||
out << '<div class="ms-2 mt-1">'
|
||||
out << menu_html(it.children, depth + 1, id)
|
||||
out << '</div>'
|
||||
out << '</div>'
|
||||
out << '</div>'
|
||||
} else {
|
||||
// leaf
|
||||
out << '<div class="list-group-item menu-leaf"><a href="${if it.href.len > 0 {
|
||||
it.href
|
||||
} else {
|
||||
'/admin'
|
||||
}}">${it.title}</a></div>'
|
||||
}
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
// Default sample menu
|
||||
fn default_menu() []MenuItem {
|
||||
return [
|
||||
MenuItem{
|
||||
title: 'Dashboard'
|
||||
href: '/admin'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'HeroScript'
|
||||
href: '/admin/heroscript'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Chat'
|
||||
href: '/admin/chat'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Users'
|
||||
children: [
|
||||
MenuItem{
|
||||
title: 'Overview'
|
||||
href: '/admin/users/overview'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Create'
|
||||
href: '/admin/users/create'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Roles'
|
||||
href: '/admin/users/roles'
|
||||
},
|
||||
]
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Content'
|
||||
children: [
|
||||
MenuItem{
|
||||
title: 'Pages'
|
||||
href: '/admin/content/pages'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Media'
|
||||
href: '/admin/content/media'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Settings'
|
||||
children: [
|
||||
MenuItem{
|
||||
title: 'SEO'
|
||||
href: '/admin/content/settings/seo'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Themes'
|
||||
href: '/admin/content/settings/themes'
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
MenuItem{
|
||||
title: 'System'
|
||||
children: [
|
||||
MenuItem{
|
||||
title: 'Status'
|
||||
href: '/admin/system/status'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Logs'
|
||||
href: '/admin/system/logs'
|
||||
},
|
||||
MenuItem{
|
||||
title: 'Backups'
|
||||
href: '/admin/system/backups'
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
243
lib/web/ui/server.v
Normal file
243
lib/web/ui/server.v
Normal file
@@ -0,0 +1,243 @@
|
||||
module ui
|
||||
|
||||
import veb
|
||||
import os
|
||||
import net.http
|
||||
import json
|
||||
import freeflowuniverse.herolib.develop.heroprompt
|
||||
|
||||
// Public Context type for veb
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
// Simple tree menu structure
|
||||
pub struct MenuItem {
|
||||
pub:
|
||||
title string
|
||||
href string
|
||||
children []MenuItem
|
||||
}
|
||||
|
||||
// Factory args
|
||||
@[params]
|
||||
pub struct FactoryArgs {
|
||||
pub mut:
|
||||
name string = 'default'
|
||||
host string = 'localhost'
|
||||
port int = 8080
|
||||
title string = 'Admin'
|
||||
menu []MenuItem
|
||||
open bool
|
||||
}
|
||||
|
||||
// The App holds server state and config
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
pub mut:
|
||||
title string
|
||||
menu []MenuItem
|
||||
port int
|
||||
}
|
||||
|
||||
pub fn new(args FactoryArgs) !&App {
|
||||
mut app := App{
|
||||
title: args.title
|
||||
menu: args.menu
|
||||
port: args.port
|
||||
}
|
||||
return &app
|
||||
}
|
||||
|
||||
// Start the webserver (blocking)
|
||||
pub fn start(args FactoryArgs) ! {
|
||||
mut app := new(args)!
|
||||
veb.run[App, Context](mut app, app.port)
|
||||
}
|
||||
|
||||
// Routes
|
||||
|
||||
// Redirect root to /admin
|
||||
@['/'; get]
|
||||
pub fn (app &App) root(mut ctx Context) veb.Result {
|
||||
return ctx.redirect('/admin')
|
||||
}
|
||||
|
||||
// Admin home page
|
||||
@['/admin'; get]
|
||||
pub fn (app &App) admin_index(mut ctx Context) veb.Result {
|
||||
return ctx.html(render_admin(app, '/', 'Welcome'))
|
||||
}
|
||||
|
||||
// HeroScript editor page
|
||||
@['/admin/heroscript'; get]
|
||||
pub fn (app &App) admin_heroscript(mut ctx Context) veb.Result {
|
||||
return ctx.html(render_heroscript(app))
|
||||
}
|
||||
|
||||
// Chat page
|
||||
@['/admin/chat'; get]
|
||||
pub fn (app &App) admin_chat(mut ctx Context) veb.Result {
|
||||
return ctx.html(render_chat(app))
|
||||
}
|
||||
|
||||
// Heroprompt page
|
||||
@['/admin/heroprompt'; get]
|
||||
pub fn (app &App) admin_heroprompt_page(mut ctx Context) veb.Result {
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'heroprompt.html')
|
||||
template_content := os.read_file(template_path) or { return ctx.text('template not found') }
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.css_heroprompt_url}}', '/static/css/heroprompt.css?v=2')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js?v=2')
|
||||
result = result.replace('{{.js_heroprompt_url}}', '/static/js/heroprompt.js?v=2')
|
||||
return ctx.html(result)
|
||||
}
|
||||
|
||||
// Static Heroprompt assets
|
||||
@['/static/css/heroprompt.css'; get]
|
||||
pub fn (app &App) serve_heroprompt_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'heroprompt.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
@['/static/js/heroprompt.js'; get]
|
||||
pub fn (app &App) serve_heroprompt_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'heroprompt.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
// Static CSS files
|
||||
@['/static/css/colors.css'; get]
|
||||
pub fn (app &App) serve_colors_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'colors.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
@['/static/css/main.css'; get]
|
||||
pub fn (app &App) serve_main_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'main.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
// Static JS files
|
||||
@['/static/js/theme.js'; get]
|
||||
pub fn (app &App) serve_theme_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'theme.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/js/heroscript.js'; get]
|
||||
pub fn (app &App) serve_heroscript_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'heroscript.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/js/chat.js'; get]
|
||||
pub fn (app &App) serve_chat_js(mut ctx Context) veb.Result {
|
||||
js_path := os.join_path(os.dir(@FILE), 'templates', 'js', 'chat.js')
|
||||
js_content := os.read_file(js_path) or { return ctx.text('/* JS file not found */') }
|
||||
ctx.set_content_type('application/javascript')
|
||||
return ctx.text(js_content)
|
||||
}
|
||||
|
||||
@['/static/css/heroscript.css'; get]
|
||||
pub fn (app &App) serve_heroscript_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'heroscript.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
@['/static/css/chat.css'; get]
|
||||
pub fn (app &App) serve_chat_css(mut ctx Context) veb.Result {
|
||||
css_path := os.join_path(os.dir(@FILE), 'templates', 'css', 'chat.css')
|
||||
css_content := os.read_file(css_path) or { return ctx.text('/* CSS file not found */') }
|
||||
ctx.set_content_type('text/css')
|
||||
return ctx.text(css_content)
|
||||
}
|
||||
|
||||
// Catch-all content under /admin/*
|
||||
@['/admin/:path...'; get]
|
||||
pub fn (app &App) admin_section(mut ctx Context, path string) veb.Result {
|
||||
// Render current path in the main content
|
||||
return ctx.html(render_admin(app, path, 'Content'))
|
||||
}
|
||||
|
||||
// Pure functions for rendering templates
|
||||
fn render_admin(app &App, path string, heading string) string {
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'admin_layout.html')
|
||||
template_content := os.read_file(template_path) or {
|
||||
return render_admin_fallback(app, path, heading)
|
||||
}
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.heading}}', heading)
|
||||
result = result.replace('{{.path}}', path)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
return result
|
||||
}
|
||||
|
||||
fn render_heroscript(app &App) string {
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'heroscript_editor.html')
|
||||
template_content := os.read_file(template_path) or { return render_heroscript_fallback(app) }
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.css_heroscript_url}}', '/static/css/heroscript.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
result = result.replace('{{.js_heroscript_url}}', '/static/js/heroscript.js')
|
||||
return result
|
||||
}
|
||||
|
||||
fn render_chat(app &App) string {
|
||||
template_path := os.join_path(os.dir(@FILE), 'templates', 'chat.html')
|
||||
template_content := os.read_file(template_path) or { return render_chat_fallback(app) }
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
mut result := template_content
|
||||
result = result.replace('{{.title}}', app.title)
|
||||
result = result.replace('{{.menu_html}}', menu_content)
|
||||
result = result.replace('{{.css_colors_url}}', '/static/css/colors.css')
|
||||
result = result.replace('{{.css_main_url}}', '/static/css/main.css')
|
||||
result = result.replace('{{.css_chat_url}}', '/static/css/chat.css')
|
||||
result = result.replace('{{.js_theme_url}}', '/static/js/theme.js')
|
||||
result = result.replace('{{.js_chat_url}}', '/static/js/chat.js')
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
fn render_heroscript_fallback(app &App) string {
|
||||
return '\n<!doctype html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<meta name="viewport" content="width=device-width, initial-scale=1">\n\t<title>${app.title} - HeroScript Editor</title>\n\t<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">\n</head>\n<body>\n\t<div class="container mt-5">\n\t\t<h1>HeroScript Editor</h1>\n\t\t<p>HeroScript editor template not found. Please check the template files.</p>\n\t\t<a href="/admin" class="btn btn-primary">Back to Admin</a>\n\t</div>\n</body>\n</html>\n'
|
||||
}
|
||||
|
||||
fn render_chat_fallback(app &App) string {
|
||||
return '\n<!doctype html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<meta name="viewport" content="width=device-width, initial-scale=1">\n\t<title>${app.title} - Chat</title>\n\t<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">\n</head>\n<body>\n\t<div class="container mt-5">\n\t\t<h1>Chat Assistant</h1>\n\t\t<p>Chat template not found. Please check the template files.</p>\n\t\t<a href="/admin" class="btn btn-primary">Back to Admin</a>\n\t</div>\n</body>\n</html>\n'
|
||||
}
|
||||
|
||||
fn render_admin_fallback(app &App, path string, heading string) string {
|
||||
return '\n<!doctype html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<meta name="viewport" content="width=device-width, initial-scale=1">\n\t<title>${app.title}</title>\n\t<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">\n\t<style>body { padding-top: 44px; } .header { height: 44px; line-height: 44px; font-size: 14px; } .sidebar { position: fixed; top: 44px; bottom: 0; left: 0; width: 260px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #e0e0e0; } .main { margin-left: 260px; padding: 16px; } .list-group-item { border: 0; padding: .35rem .75rem; background: transparent; } .menu-leaf a { color: #212529; text-decoration: none; } .menu-toggle { text-decoration: none; color: #212529; } .menu-toggle .chev { font-size: 10px; opacity: .6; } .menu-section { font-weight: 600; color: #6c757d; padding: .5rem .75rem; }</style>\n</head>\n<body>\n\t<nav class="navbar navbar-dark bg-dark fixed-top header px-2">\n\t\t<div class="d-flex w-100 align-items-center justify-content-between">\n\t\t\t<div class="text-white fw-bold">${app.title}</div>\n\t\t\t<div class="text-white-50">Admin</div>\n\t\t</div>\n\t</nav>\n\n\t<aside class="sidebar">\n\t\t<div class="p-2">\n\t\t\t<div class="menu-section">Navigation</div>\n\t\t\t<div class="list-group list-group-flush">\n\t\t\t\t${menu_html(app.menu,
|
||||
0, 'm')}\n\t\t\t</div>\n\t\t</div>\n\t</aside>\n\n\t<main class="main">\n\t\t<div class="container-fluid">\n\t\t\t<div class="d-flex align-items-center mb-3">\n\t\t\t\t<h5 class="mb-0">${heading}</h5>\n\t\t\t\t<span class="ms-2 text-muted small">/admin/${path}</span>\n\t\t\t</div>\n\t\t\t<div class="card">\n\t\t\t\t<div class="card-body">\n\t\t\t\t\t<p class="text-muted">This is a placeholder admin content area for: <code>/admin/${path}</code>.</p>\n\t\t\t\t\t<p class="mb-0">Use the treeview on the left to navigate.</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</main>\n\n\t<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>\n</body>\n</html>\n'
|
||||
}
|
||||
@@ -45,10 +45,11 @@
|
||||
<h6 class="mb-0">Workspaces</h6>
|
||||
<button id="create-workspace" class="btn btn-sm btn-primary">New</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="workspace-list" class="list-group list-group-flush">
|
||||
<!-- Workspaces will be populated by JavaScript -->
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label for="workspace-select" class="form-label small text-muted">Select workspace</label>
|
||||
<select id="workspace-select" class="form-select form-select-sm mb-2"></select>
|
||||
<button id="delete-workspace" class="btn btn-sm btn-outline-danger"
|
||||
style="display:none;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,16 @@
|
||||
|
||||
class Heroprompt {
|
||||
constructor() {
|
||||
this.storageKey = 'heroprompt_data';
|
||||
this.data = this.loadData();
|
||||
this.currentWorkspace = this.data.current || 'default';
|
||||
|
||||
// Ensure default workspace exists
|
||||
if (!this.data.workspaces.default) {
|
||||
this.data.workspaces.default = { dirs: [] };
|
||||
this.saveData();
|
||||
}
|
||||
// Backend-integrated state (no localStorage)
|
||||
this.currentWorkspace = '';
|
||||
this.workspaces = [];
|
||||
this.selectedFiles = new Set();
|
||||
this.selectedDirs = new Set();
|
||||
|
||||
this.initializeUI();
|
||||
this.bindEvents();
|
||||
this.render();
|
||||
// Load workspaces from backend and render
|
||||
this.refreshWorkspaces();
|
||||
}
|
||||
|
||||
// Data management
|
||||
@@ -51,7 +48,8 @@ class Heroprompt {
|
||||
// API calls to V backend
|
||||
async fetchDirectory(path) {
|
||||
try {
|
||||
const response = await fetch(`/api/heroprompt/directory?path=${encodeURIComponent(path)}`);
|
||||
const qs = new URLSearchParams({ name: this.currentWorkspace || 'default', path }).toString();
|
||||
const response = await fetch(`/api/heroprompt/directory?${qs}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -64,7 +62,8 @@ class Heroprompt {
|
||||
|
||||
async fetchFileContent(path) {
|
||||
try {
|
||||
const response = await fetch(`/api/heroprompt/file?path=${encodeURIComponent(path)}`);
|
||||
const qs = new URLSearchParams({ name: this.currentWorkspace || 'default', path }).toString();
|
||||
const response = await fetch(`/api/heroprompt/file?${qs}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -139,6 +138,108 @@ class Heroprompt {
|
||||
this.addDirectoryWithPrompt();
|
||||
}
|
||||
}
|
||||
// Backend API helpers
|
||||
async apiListWorkspaces() {
|
||||
const res = await fetch('/api/heroprompt/workspaces');
|
||||
if (!res.ok) throw new Error('Failed to list workspaces');
|
||||
return await res.json(); // array of names
|
||||
}
|
||||
|
||||
async apiCreateWorkspace(name, base_path = '') {
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
if (base_path) form.append('base_path', base_path);
|
||||
const res = await fetch('/api/heroprompt/workspaces', { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error('Failed to create workspace');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async apiDeleteWorkspace(name) {
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete workspace');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async refreshWorkspaces(selectName = '') {
|
||||
try {
|
||||
this.workspaces = await this.apiListWorkspaces();
|
||||
if (this.workspaces.length > 0) {
|
||||
this.currentWorkspace = selectName || this.currentWorkspace || this.workspaces[0];
|
||||
} else {
|
||||
this.currentWorkspace = '';
|
||||
}
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.showToast('Failed to load workspaces', 'error');
|
||||
}
|
||||
}
|
||||
async apiGetWorkspace(name) {
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(name)}`);
|
||||
if (!res.ok) throw new Error('Failed to get workspace');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async apiAddDir(path) {
|
||||
const form = new FormData();
|
||||
form.append('path', path);
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/dirs`, {
|
||||
method: 'POST', body: form
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add directory');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async apiRemoveDir(path) {
|
||||
const form = new FormData();
|
||||
form.append('path', path);
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/dirs/remove`, {
|
||||
method: 'POST', body: form
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove directory');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async apiAddFile(path) {
|
||||
const form = new FormData();
|
||||
form.append('path', path);
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/files`, {
|
||||
method: 'POST', body: form
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add file');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async apiRemoveFile(path) {
|
||||
const form = new FormData();
|
||||
form.append('path', path);
|
||||
const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/files/remove`, {
|
||||
method: 'POST', body: form
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove file');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async loadCurrentWorkspaceDetails() {
|
||||
if (!this.currentWorkspace) {
|
||||
this.currentDetails = null;
|
||||
this.selectedFiles = new Set();
|
||||
this.selectedDirs = new Set();
|
||||
return;
|
||||
}
|
||||
const data = await this.apiGetWorkspace(this.currentWorkspace);
|
||||
this.currentDetails = data;
|
||||
const files = new Set();
|
||||
const dirs = new Set();
|
||||
for (const ch of (data.children || [])) {
|
||||
if (ch.type === 'file') files.add(ch.path);
|
||||
if (ch.type === 'directory') dirs.add(ch.path);
|
||||
}
|
||||
this.selectedFiles = files;
|
||||
this.selectedDirs = dirs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async addDirectoryWithPicker() {
|
||||
try {
|
||||
@@ -219,72 +320,46 @@ class Heroprompt {
|
||||
}
|
||||
}
|
||||
|
||||
addDirectoryWithPrompt() {
|
||||
async addDirectoryWithPrompt() {
|
||||
const path = prompt('Enter directory path:');
|
||||
if (!path || path.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchDirectory(path.trim())
|
||||
.then(dirData => {
|
||||
// Check if we got an error response
|
||||
if (dirData.error) {
|
||||
throw new Error(dirData.error);
|
||||
}
|
||||
|
||||
const processedDir = {
|
||||
path: dirData.path || path.trim(),
|
||||
files: [],
|
||||
subdirs: [],
|
||||
selected: []
|
||||
};
|
||||
|
||||
// Separate files and directories
|
||||
if (dirData.items && Array.isArray(dirData.items)) {
|
||||
for (const item of dirData.items) {
|
||||
if (item.type === 'file') {
|
||||
processedDir.files.push(item.name);
|
||||
} else if (item.type === 'directory') {
|
||||
processedDir.subdirs.push(item.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.data.workspaces[this.currentWorkspace].dirs.push(processedDir);
|
||||
this.saveData();
|
||||
this.render();
|
||||
if (!path || path.trim() === '') return;
|
||||
try {
|
||||
await this.apiAddDir(path.trim());
|
||||
await this.render();
|
||||
this.showToast(`Directory "${path}" added`, 'success');
|
||||
})
|
||||
.catch(e => {
|
||||
} catch (e) {
|
||||
console.error('Failed to add directory:', e);
|
||||
this.showToast(`Failed to add directory: ${e.message}`, 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeDirectory(workspaceName, dirIndex) {
|
||||
if (!confirm('Are you sure you want to remove this directory?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data.workspaces[workspaceName].dirs.splice(dirIndex, 1);
|
||||
this.saveData();
|
||||
this.render();
|
||||
async removeDirectory(path) {
|
||||
if (!confirm('Are you sure you want to remove this directory?')) return;
|
||||
try {
|
||||
await this.apiRemoveDir(path);
|
||||
await this.render();
|
||||
this.showToast('Directory removed', 'success');
|
||||
} catch (e) {
|
||||
console.error('Failed to remove directory:', e);
|
||||
this.showToast('Failed to remove directory', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// File selection management
|
||||
toggleFileSelection(workspaceName, dirIndex, fileName) {
|
||||
const dir = this.data.workspaces[workspaceName].dirs[dirIndex];
|
||||
const selectedIndex = dir.selected.indexOf(fileName);
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
dir.selected.push(fileName);
|
||||
// File selection management (backend-synced)
|
||||
async toggleFileSelection(filePath) {
|
||||
try {
|
||||
if (this.selectedFiles.has(filePath)) {
|
||||
await this.apiRemoveFile(filePath);
|
||||
this.selectedFiles.delete(filePath);
|
||||
} else {
|
||||
dir.selected.splice(selectedIndex, 1);
|
||||
await this.apiAddFile(filePath);
|
||||
this.selectedFiles.add(filePath);
|
||||
}
|
||||
await this.renderWorkspaceDetails();
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle file selection:', e);
|
||||
this.showToast('Failed to toggle file selection', 'error');
|
||||
}
|
||||
|
||||
this.saveData();
|
||||
this.renderWorkspaceDetails();
|
||||
}
|
||||
|
||||
selectAllFiles(workspaceName, dirIndex) {
|
||||
@@ -325,9 +400,8 @@ class Heroprompt {
|
||||
|
||||
// Clipboard functionality with new format
|
||||
async copySelection() {
|
||||
const workspace = this.data.workspaces[this.currentWorkspace];
|
||||
if (!workspace || workspace.dirs.length === 0) {
|
||||
this.showToast('No directories in workspace', 'error');
|
||||
if (!this.currentWorkspace) {
|
||||
this.showToast('Select a workspace first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -348,17 +422,18 @@ class Heroprompt {
|
||||
|
||||
// Generate file map
|
||||
output.push('<file_map>');
|
||||
for (const dir of workspace.dirs) {
|
||||
if (dir.selected.length > 0) {
|
||||
hasSelection = true;
|
||||
const fileTree = this.generateFileTree(dir.path, dir.files, dir.subdirs || []);
|
||||
const dirSet = new Set(Array.from(this.selectedFiles).map(p => p.split('/').slice(0, -1).join('/')));
|
||||
for (const dirPath of dirSet) {
|
||||
const data = await this.fetchDirectory(dirPath);
|
||||
const files = (data.items || []).filter(it => it.type === 'file').map(it => it.name);
|
||||
const subdirs = (data.items || []).filter(it => it.type === 'directory').map(it => it.name);
|
||||
const fileTree = this.generateFileTree(dirPath, files, subdirs);
|
||||
output.push(fileTree);
|
||||
}
|
||||
}
|
||||
output.push('</file_map>');
|
||||
output.push('');
|
||||
|
||||
if (!hasSelection) {
|
||||
if (this.selectedFiles.size === 0) {
|
||||
this.showToast('No files selected', 'error');
|
||||
return;
|
||||
}
|
||||
@@ -367,9 +442,7 @@ class Heroprompt {
|
||||
output.push('<file_contents>');
|
||||
|
||||
try {
|
||||
for (const dir of workspace.dirs) {
|
||||
for (const fileName of dir.selected) {
|
||||
const filePath = `${dir.path}/${fileName}`;
|
||||
for (const filePath of Array.from(this.selectedFiles)) {
|
||||
try {
|
||||
const fileData = await this.fetchFileContent(filePath);
|
||||
output.push(`File: ${filePath}`);
|
||||
@@ -388,7 +461,6 @@ class Heroprompt {
|
||||
output.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file contents:', e);
|
||||
this.showToast('Error fetching file contents', 'error');
|
||||
@@ -437,7 +509,7 @@ class Heroprompt {
|
||||
initializeUI() {
|
||||
// Cache DOM elements
|
||||
this.elements = {
|
||||
workspaceList: document.getElementById('workspace-list'),
|
||||
workspaceSelect: document.getElementById('workspace-select'),
|
||||
workspaceContent: document.getElementById('workspace-content'),
|
||||
currentWorkspaceName: document.getElementById('current-workspace-name'),
|
||||
createWorkspaceBtn: document.getElementById('create-workspace'),
|
||||
@@ -452,15 +524,36 @@ class Heroprompt {
|
||||
|
||||
bindEvents() {
|
||||
// Workspace management
|
||||
this.elements.createWorkspaceBtn.addEventListener('click', () => {
|
||||
this.elements.createWorkspaceBtn.addEventListener('click', async () => {
|
||||
const name = prompt('Enter workspace name:');
|
||||
if (name) {
|
||||
this.createWorkspace(name);
|
||||
if (!name) return;
|
||||
const base_path = prompt('Enter base path for this workspace (optional):') || '';
|
||||
try {
|
||||
await this.apiCreateWorkspace(name.trim(), base_path.trim());
|
||||
await this.refreshWorkspaces(name.trim());
|
||||
this.showToast(`Workspace "${name}" created`, 'success');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.showToast('Failed to create workspace', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
this.elements.deleteWorkspaceBtn.addEventListener('click', () => {
|
||||
this.deleteWorkspace(this.currentWorkspace);
|
||||
this.elements.deleteWorkspaceBtn.addEventListener('click', async () => {
|
||||
if (!this.currentWorkspace) return;
|
||||
if (!confirm(`Are you sure you want to delete workspace "${this.currentWorkspace}"?`)) return;
|
||||
try {
|
||||
await this.apiDeleteWorkspace(this.currentWorkspace);
|
||||
await this.refreshWorkspaces();
|
||||
this.showToast('Workspace deleted', 'success');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.showToast('Failed to delete workspace', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
this.elements.workspaceSelect.addEventListener('change', async () => {
|
||||
this.currentWorkspace = this.elements.workspaceSelect.value;
|
||||
await this.render();
|
||||
});
|
||||
|
||||
// Directory management
|
||||
@@ -480,30 +573,16 @@ class Heroprompt {
|
||||
}
|
||||
|
||||
render() {
|
||||
this.renderWorkspaceList();
|
||||
this.renderWorkspaceSelect();
|
||||
this.renderWorkspaceDetails();
|
||||
}
|
||||
|
||||
renderWorkspaceList() {
|
||||
const workspaceNames = Object.keys(this.data.workspaces).sort();
|
||||
|
||||
this.elements.workspaceList.innerHTML = workspaceNames.map(name => `
|
||||
<div class="list-group-item workspace-item ${name === this.currentWorkspace ? 'active' : ''}"
|
||||
data-workspace="${name}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>${name}</span>
|
||||
<small class="text-muted">${this.data.workspaces[name].dirs.length} dirs</small>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Bind workspace selection events
|
||||
this.elements.workspaceList.querySelectorAll('.workspace-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const workspaceName = item.dataset.workspace;
|
||||
this.selectWorkspace(workspaceName);
|
||||
});
|
||||
});
|
||||
renderWorkspaceSelect() {
|
||||
const names = this.workspaces;
|
||||
const options = names.map(n => `<option value="${n}" ${n === this.currentWorkspace ? 'selected' : ''}>${n}</option>`).join('');
|
||||
this.elements.workspaceSelect.innerHTML = options;
|
||||
this.elements.deleteWorkspaceBtn.style.display = this.currentWorkspace && this.currentWorkspace !== 'default' ? 'inline-block' : 'none';
|
||||
this.elements.currentWorkspaceName.textContent = this.currentWorkspace || 'Select a workspace';
|
||||
}
|
||||
|
||||
renderWorkspaceDetails() {
|
||||
@@ -513,7 +592,7 @@ class Heroprompt {
|
||||
this.elements.currentWorkspaceName.textContent = this.currentWorkspace;
|
||||
|
||||
// Show/hide buttons based on selection
|
||||
const hasWorkspace = !!workspace;
|
||||
const hasWorkspace = !!this.currentWorkspace;
|
||||
this.elements.deleteWorkspaceBtn.style.display = hasWorkspace && this.currentWorkspace !== 'default' ? 'inline-block' : 'none';
|
||||
this.elements.addDirectoryBtn.style.display = hasWorkspace ? 'inline-block' : 'none';
|
||||
this.elements.copySelectionBtn.style.display = hasWorkspace ? 'inline-block' : 'none';
|
||||
@@ -534,39 +613,110 @@ class Heroprompt {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render directories and files
|
||||
this.elements.workspaceContent.innerHTML = workspace.dirs.map((dir, dirIndex) => `
|
||||
// Load current workspace details from backend
|
||||
try {
|
||||
await this.loadCurrentWorkspaceDetails();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.elements.workspaceContent.innerHTML = '<p class="text-muted">Failed to load workspace details.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const dirs = Array.from(this.selectedDirs);
|
||||
if (dirs.length === 0) {
|
||||
this.elements.workspaceContent.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📁</div>
|
||||
<p>No directories added to this workspace.</p>
|
||||
<p class="text-muted">Click "Add Directory" to get started.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render directories as expandable tree explorers
|
||||
this.elements.workspaceContent.innerHTML = dirs.map((dirPath, dirIndex) => `
|
||||
<div class="directory-item">
|
||||
<div class="directory-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${this.getDirectoryName(dir.path)}</strong>
|
||||
<div class="directory-path">${dir.path}</div>
|
||||
${dir.subdirs && dir.subdirs.length > 0 ? `<div class="text-muted small">Subdirs: ${dir.subdirs.join(', ')}</div>` : ''}
|
||||
<strong>${this.getDirectoryName(dirPath)}</strong>
|
||||
<div class="directory-path">${dirPath}</div>
|
||||
</div>
|
||||
<div class="btn-group-actions">
|
||||
<span class="selection-counter ${dir.selected.length > 0 ? 'has-selection' : ''}">
|
||||
${dir.selected.length}/${dir.files.length} selected
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="heroprompt.selectAllFiles('${this.currentWorkspace}', ${dirIndex})">All</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="heroprompt.deselectAllFiles('${this.currentWorkspace}', ${dirIndex})">None</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="heroprompt.removeDirectory('${this.currentWorkspace}', ${dirIndex})">Remove</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="heroprompt.removeDirectory('${dirPath}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
${dir.files.map(file => `
|
||||
<div class="file-item ${dir.selected.includes(file) ? 'selected' : ''}"
|
||||
onclick="heroprompt.toggleFileSelection('${this.currentWorkspace}', ${dirIndex}, '${file}')">
|
||||
<input type="checkbox" ${dir.selected.includes(file) ? 'checked' : ''}
|
||||
onclick="event.stopPropagation()">
|
||||
<span class="file-icon ${this.getFileIconClass(file)}"></span>
|
||||
<span class="file-name">${file}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="file-tree" id="file-tree-${dirIndex}"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Populate tree nodes asynchronously
|
||||
dirs.forEach((dirPath, dirIndex) => {
|
||||
const container = document.getElementById(`file-tree-${dirIndex}`);
|
||||
this.renderDirNode(dirPath, dirIndex, container, 0);
|
||||
});
|
||||
}
|
||||
// Render a directory node with lazy subdir loading
|
||||
async renderDirNode(dirPath, dirIndex, container, level) {
|
||||
try {
|
||||
// Fetch directory listing from backend
|
||||
const data = await this.fetchDirectory(dirPath);
|
||||
const items = data.items || [];
|
||||
|
||||
// Separate directories and files
|
||||
const dirs = items.filter(it => it.type === 'directory').map(it => it.name);
|
||||
const files = items.filter(it => it.type === 'file').map(it => it.name);
|
||||
|
||||
// Build HTML
|
||||
const indent = ' '.repeat(level * 2);
|
||||
const list = [];
|
||||
|
||||
dirs.forEach(sub => {
|
||||
const subPath = `${dirPath}/${sub}`;
|
||||
const nodeId = `dir-node-${dirIndex}-${level}-${sub.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||
list.push(`
|
||||
<div class="file-item">
|
||||
<span class="file-icon">📁</span>
|
||||
<a href="#" class="toggle" data-target="${nodeId}" data-path="${subPath}">${indent}${sub}</a>
|
||||
<div id="${nodeId}" class="children" style="display:none; margin-left:12px;"></div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
files.forEach(file => {
|
||||
const absPath = `${dirPath}/${file}`;
|
||||
const isSel = this.selectedFiles.has(absPath);
|
||||
list.push(`
|
||||
<div class="file-item ${isSel ? 'selected' : ''}" onclick="heroprompt.toggleFileSelection('${absPath}')">
|
||||
<input type="checkbox" ${isSel ? 'checked' : ''} onclick="event.stopPropagation()">
|
||||
<span class="file-icon ${this.getFileIconClass(file)}"></span>
|
||||
<span class="file-name">${indent}${file}</span>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
container.innerHTML = list.join('');
|
||||
|
||||
// Bind toggles for lazy load
|
||||
container.querySelectorAll('a.toggle').forEach(a => {
|
||||
a.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const targetId = a.getAttribute('data-target');
|
||||
const path = a.getAttribute('data-path');
|
||||
const target = document.getElementById(targetId);
|
||||
if (target.getAttribute('data-loaded') !== '1') {
|
||||
await this.renderDirNode(path, dirIndex, target, level + 1);
|
||||
target.setAttribute('data-loaded', '1');
|
||||
}
|
||||
target.style.display = (target.style.display === 'none') ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to render directory node:', e);
|
||||
container.innerHTML = `<div class="text-muted small">Failed to load ${dirPath}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
getDirectoryName(path) {
|
||||
|
||||
27
lib/web/ui/utils.v
Normal file
27
lib/web/ui/utils.v
Normal file
@@ -0,0 +1,27 @@
|
||||
module ui
|
||||
|
||||
// Recursive menu renderer
|
||||
fn menu_html(items []MenuItem, depth int, prefix string) string {
|
||||
mut out := []string{}
|
||||
for i, it in items {
|
||||
id := '${prefix}_${depth}_${i}'
|
||||
if it.children.len > 0 {
|
||||
// expandable group
|
||||
out << '<div class="item">'
|
||||
out << '<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-bs-toggle="collapse" href="#${id}" role="button" aria-expanded="${if depth == 0 { 'true' } else { 'false' }}" aria-controls="${id}">'
|
||||
out << '<span>${it.title}</span><span class="chev">›</span>'
|
||||
out << '</a>'
|
||||
out << '<div class="collapse ${if depth == 0 { 'show' } else { '' }}" id="${id}">'
|
||||
out << '<div class="ms-2 mt-1">'
|
||||
out << menu_html(it.children, depth + 1, id)
|
||||
out << '</div>'
|
||||
out << '</div>'
|
||||
out << '</div>'
|
||||
} else {
|
||||
// leaf
|
||||
out << '<a href="${it.href}" class="list-group-item list-group-item-action">${it.title}</a>'
|
||||
}
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user