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:
parent
9260582072
commit
c941f05dab
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = '' }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue