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 {
|
type NodeDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
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"`
|
||||||
CreatedAt string `json:"created_at"`
|
HasChildren bool `json:"has_children"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateDTO struct {
|
type TemplateDTO struct {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = '' }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue