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

@ -112,6 +112,7 @@ type NodeDTO struct {
FsPath string `json:"fs_path"` FsPath string `json:"fs_path"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
Archived bool `json:"archived"` Archived bool `json:"archived"`
HasChildren bool `json:"has_children"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }

View File

@ -19,7 +19,15 @@ func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
if err != nil { if err != nil {
return nil, err 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) { func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
@ -27,7 +35,15 @@ func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
if err != nil { if err != nil {
return nil, err 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 { 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; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Dt9BLCiY.js"></script> <script type="module" crossorigin src="/assets/main-y7k2Kn7r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHi9sCXv.css"> <link rel="stylesheet" crossorigin href="/assets/main-CJu7OoK7.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"verstak/internal/core/storage" "verstak/internal/core/storage"
@ -168,6 +169,22 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
return scanNodes(rows) 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. // ListByParent returns children as *Node pointers. parentID must not be empty.
func (r *Repository) ListByParent(parentID string) ([]*Node, error) { func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
rows, err := r.db.Query( rows, err := r.db.Query(