gui: sidebar tree UX fixes — has_children, preserve expanded, double-click, DnD visual

- Backend: add HasChildren field to NodeDTO; ListWorkspaceTree/ListChildren
  populate it by querying CountChildren for container types
- Node repository: add CountChildren(parentID, types...) method
- TreeNode: toggle shown only when has_children (not isContainer); double-click
  on row = expand/collapse; icon click = expand/collapse; drop-valid class via
  DOM classList for DnD highlight; auto-expand collapsed container on 500ms
  hover during drag; auto-scroll near edge during drag
- App.svelte: selectNode no longer resets workspaceTree/expanded; new
  reloadTreePreservingExpanded() helper re-fetches children for all expanded
  nodes after DnD move / delete; deleteWorkspaceNode preserves expanded state
This commit is contained in:
2026-06-03 03:48:53 +08:00
parent 9260582072
commit c941f05dab
10 changed files with 111 additions and 65 deletions
+17
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"verstak/internal/core/storage"
@@ -168,6 +169,22 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
return scanNodes(rows)
}
// CountChildren returns the number of non-deleted children for a parent,
// optionally filtered by one or more types.
func (r *Repository) CountChildren(parentID string, types ...string) (int, error) {
q := `SELECT COUNT(*) FROM nodes WHERE parent_id = ? AND deleted_at IS NULL`
args := []interface{}{parentID}
if len(types) > 0 {
q += " AND type IN (" + strings.Repeat("?,", len(types)-1) + "?)"
for _, t := range types {
args = append(args, t)
}
}
var count int
err := r.db.QueryRow(q, args...).Scan(&count)
return count, err
}
// ListByParent returns children as *Node pointers. parentID must not be empty.
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
rows, err := r.db.Query(