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:
mirivlad 2026-06-03 03:48:53 +08:00
parent 9260582072
commit c941f05dab
10 changed files with 111 additions and 65 deletions

View File

@ -104,16 +104,17 @@ func (a *App) autoSyncLoop() {
// ============================================================
type NodeDTO struct {
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
HasChildren bool `json:"has_children"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type TemplateDTO struct {

View File

@ -19,7 +19,15 @@ func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
if err != nil {
return nil, err
}
return filterContainers(toNodeDTOs(list)), nil
dtos := filterContainers(toNodeDTOs(list))
for i := range dtos {
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
if err != nil {
return nil, err
}
dtos[i].HasChildren = n > 0
}
return dtos, nil
}
func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
@ -27,7 +35,15 @@ func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
if err != nil {
return nil, err
}
return filterContainers(toNodeDTOs(list)), nil
dtos := filterContainers(toNodeDTOs(list))
for i := range dtos {
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
if err != nil {
return nil, err
}
dtos[i].HasChildren = n > 0
}
return dtos, nil
}
func filterContainers(dtos []NodeDTO) []NodeDTO {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-Dt9BLCiY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHi9sCXv.css">
<script type="module" crossorigin src="/assets/main-y7k2Kn7r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CJu7OoK7.css">
</head>
<body>
<div id="app"></div>

View File

@ -195,9 +195,6 @@
files = []
actions = []
worklog = []
treeItems = []
expanded = {}
childrenMap = {}
fileItems = []
folderStack = []
currentFolderId = null
@ -696,7 +693,24 @@
if (!draggedId || !targetId || draggedId === targetId) return
try {
const moved = await wailsCall('MoveNode', draggedId, targetId)
workspaceTree = await wailsCall('ListWorkspaceTree') || []
await reloadTreePreservingExpanded()
const refreshed = await wailsCall('GetNodeDetail', draggedId)
if (refreshed) {
selectedSection = ''
selectNode(refreshed)
}
} catch (e) { error = String(e) }
}
async function handleDropRoot(e) {
e.preventDefault()
try {
const draggedId = e.dataTransfer.getData('text/plain')
if (!draggedId) return
const detail = await wailsCall('GetNodeDetail', draggedId)
if (!detail || !detail.parent_id) return
await wailsCall('MoveNode', draggedId, '')
await reloadTreePreservingExpanded()
const refreshed = await wailsCall('GetNodeDetail', draggedId)
if (refreshed) {
selectedSection = ''
@ -710,23 +724,6 @@
e.dataTransfer.dropEffect = 'move'
}
async function handleDropRoot(e) {
e.preventDefault()
try {
const draggedId = e.dataTransfer.getData('text/plain')
if (!draggedId) return
const detail = await wailsCall('GetNodeDetail', draggedId)
if (!detail || !detail.parent_id) return
await wailsCall('MoveNode', draggedId, '')
workspaceTree = await wailsCall('ListWorkspaceTree') || []
const refreshed = await wailsCall('GetNodeDetail', draggedId)
if (refreshed) {
selectedSection = ''
selectNode(refreshed)
}
} catch (e) { error = String(e) }
}
// ===== Node operations from context menu =====
function openRenameForNode(node) {
openRename(node.id, node.title)
@ -743,7 +740,7 @@
onConfirm: async () => {
try {
await wailsCall('DeleteNode', node.id)
workspaceTree = await wailsCall('ListWorkspaceTree') || workspaceTree
await reloadTreePreservingExpanded()
if (selectedNode && selectedNode.id === node.id) {
selectedNode = null
}
@ -759,6 +756,17 @@
} catch (e) { error = String(e) }
}
async function reloadTreePreservingExpanded() {
workspaceTree = await wailsCall('ListWorkspaceTree') || []
for (const nodeId of Object.keys(expanded)) {
if (expanded[nodeId]) {
const children = await wailsCall('ListWorkspaceChildren', nodeId) || []
setNodeChildren(workspaceTree, nodeId, children)
}
}
workspaceTree = [...workspaceTree]
}
// ===== Notes =====
function openCreateNote() { showCreateNote = true; newNoteTitle = '' }
function cancelCreateNote() { showCreateNote = false; newNoteTitle = '' }

View File

@ -13,6 +13,8 @@
let autoExpandTimers = {}
let scrollInterval = null
const CONTAINER_TYPES = ['folder', 'project', 'client', 'document', 'recipe', 'case']
function iconKind(node) {
if (node.type === 'client' || node.template_id === 'client.default') return 'client'
if (node.type === 'project' || node.template_id === 'project.default') return 'project'
@ -26,13 +28,20 @@
}
function isContainer(node) {
return ['folder', 'project', 'client', 'document', 'recipe', 'case'].includes(node.type)
return CONTAINER_TYPES.includes(node.type)
}
function hasLoadedChildren(node) {
return node.children !== undefined
}
function shouldShowToggle(node) {
if (hasLoadedChildren(node)) {
return node.children.length > 0
}
return node.has_children === true
}
function canDrop(target, draggedId) {
if (!target || !draggedId) return false
if (draggedId === target.id) return false
@ -66,36 +75,33 @@
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
e.currentTarget.classList.add('drag-over')
if (isContainer(node) && !expanded[node.id] && !autoExpandTimers[node.id]) {
e.currentTarget.classList.add('drop-valid')
if (shouldShowToggle(node) && !expanded[node.id] && !autoExpandTimers[node.id]) {
autoExpandTimers[node.id] = setTimeout(() => {
if (onToggle) onToggle(node.id)
delete autoExpandTimers[node.id]
}, 500)
}
const rect = e.currentTarget.closest('.workspace-tree-area') || document.querySelector('.workspace-tree-area')
if (rect) {
const areaRect = rect.getBoundingClientRect()
const area = e.currentTarget.closest('.workspace-tree-area')
if (area) {
const areaRect = area.getBoundingClientRect()
const threshold = 30
if (e.clientY - areaRect.top < threshold) {
if (!scrollInterval) {
scrollInterval = setInterval(() => { rect.scrollTop -= 10 }, 50)
}
if (!scrollInterval) scrollInterval = setInterval(() => { area.scrollTop -= 10 }, 50)
} else if (areaRect.bottom - e.clientY < threshold) {
if (!scrollInterval) {
scrollInterval = setInterval(() => { rect.scrollTop += 10 }, 50)
}
if (!scrollInterval) scrollInterval = setInterval(() => { area.scrollTop += 10 }, 50)
} else {
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
}
}
}
function handleDragLeave(e, node) {
e.currentTarget.classList.remove('drag-over')
if (autoExpandTimers[node.id]) {
clearTimeout(autoExpandTimers[node.id])
delete autoExpandTimers[node.id]
function handleDragLeave(e) {
e.currentTarget.classList.remove('drop-valid')
const nodeId = e.currentTarget.dataset.nodeId
if (nodeId && autoExpandTimers[nodeId]) {
clearTimeout(autoExpandTimers[nodeId])
delete autoExpandTimers[nodeId]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
}
@ -103,7 +109,7 @@
function handleDrop(e, node) {
e.preventDefault()
e.stopPropagation()
e.currentTarget.classList.remove('drag-over')
e.currentTarget.classList.remove('drop-valid')
if (autoExpandTimers[node.id]) {
clearTimeout(autoExpandTimers[node.id])
delete autoExpandTimers[node.id]
@ -115,19 +121,17 @@
}
function handleRowClick(e, node) {
const toggleEl = e.target.closest('.tree-toggle')
const iconEl = e.target.closest('.tree-icon')
if (toggleEl || iconEl) return
if (e.target.closest('.tree-toggle') || e.target.closest('.tree-icon')) return
if (onSelect) onSelect(node)
}
function handleRowDblClick(e, node) {
if (isContainer(node) && onToggle) onToggle(node.id)
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
function handleIconClick(e, node) {
e.stopPropagation()
if (isContainer(node) && onToggle) onToggle(node.id)
if (shouldShowToggle(node) && onToggle) onToggle(node.id)
}
</script>
@ -143,7 +147,7 @@
on:click={(e) => handleRowClick(e, node)}
on:dblclick={(e) => handleRowDblClick(e, node)}
on:contextmenu|preventDefault={(e) => onContextMenu && onContextMenu(e, node)}>
{#if isContainer(node)}
{#if shouldShowToggle(node)}
<button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}>
<span class="tree-arrow">{expanded[node.id] ? '▾' : '▸'}</span>
</button>
@ -182,7 +186,7 @@
color: #fff;
font-weight: 500;
}
.tree-item.drag-over {
.tree-item.drop-valid {
background: #1a3a1a;
outline: 1px solid #4ade80;
outline-offset: -1px;

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(