gui: sidebar tree model fix — only container nodes, improved DnD + context menu

- Backend: ListWorkspaceTree/ListWorkspaceChildren filter to container types
  only (case, client, project, folder, document, recipe)
- TreeNode: full-row context menu (removed label stopPropagation),
  double-click toggles expand, icon-click toggles expand, DnD auto-expand
  on 500ms hover, auto-scroll near edges, drag-over highlight via classList
- App.svelte: toggleExpand uses ListWorkspaceChildren, submitCreateNode uses
  ListWorkspaceChildren for child tree population
- Note/file nodes no longer appear in the sidebar workspace tree
This commit is contained in:
mirivlad 2026-06-03 03:33:13 +08:00
parent b2dcb116c9
commit 9260582072
8 changed files with 104 additions and 20 deletions

View File

@ -19,7 +19,26 @@ func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toNodeDTOs(list), nil return filterContainers(toNodeDTOs(list)), nil
}
func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return filterContainers(toNodeDTOs(list)), nil
}
func filterContainers(dtos []NodeDTO) []NodeDTO {
out := make([]NodeDTO, 0, len(dtos))
for _, d := range dtos {
switch d.Type {
case "case", "client", "project", "folder", "document", "recipe":
out = append(out, d)
}
}
return out
} }
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) { func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {

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-CBFn_7kC.js"></script> <script type="module" crossorigin src="/assets/main-Dt9BLCiY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-C49DuBq6.css"> <link rel="stylesheet" crossorigin href="/assets/main-CHi9sCXv.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -643,7 +643,7 @@
createWithTemplate = undefined createWithTemplate = undefined
if (parentID) { if (parentID) {
expanded = { ...expanded, [parentID]: true } expanded = { ...expanded, [parentID]: true }
const children = await wailsCall('ListChildren', parentID) || [] const children = await wailsCall('ListWorkspaceChildren', parentID) || []
setNodeChildren(workspaceTree, parentID, children) setNodeChildren(workspaceTree, parentID, children)
workspaceTree = [...workspaceTree] workspaceTree = [...workspaceTree]
} else { } else {
@ -673,7 +673,7 @@
const willExpand = !expanded[nodeId] const willExpand = !expanded[nodeId]
expanded = { ...expanded, [nodeId]: willExpand } expanded = { ...expanded, [nodeId]: willExpand }
if (!willExpand) return if (!willExpand) return
const children = await wailsCall('ListChildren', nodeId) || [] const children = await wailsCall('ListWorkspaceChildren', nodeId) || []
setNodeChildren(workspaceTree, nodeId, children) setNodeChildren(workspaceTree, nodeId, children)
workspaceTree = [...workspaceTree] workspaceTree = [...workspaceTree]
} }

View File

@ -10,6 +10,9 @@
export let onContextMenu export let onContextMenu
export let onDrop export let onDrop
let autoExpandTimers = {}
let scrollInterval = null
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'
@ -63,15 +66,69 @@
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
e.dataTransfer.dropEffect = 'move' e.dataTransfer.dropEffect = 'move'
e.currentTarget.classList.add('drag-over')
if (isContainer(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 threshold = 30
if (e.clientY - areaRect.top < threshold) {
if (!scrollInterval) {
scrollInterval = setInterval(() => { rect.scrollTop -= 10 }, 50)
}
} else if (areaRect.bottom - e.clientY < threshold) {
if (!scrollInterval) {
scrollInterval = setInterval(() => { rect.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]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
} }
function handleDrop(e, node) { function handleDrop(e, node) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
e.currentTarget.classList.remove('drag-over')
if (autoExpandTimers[node.id]) {
clearTimeout(autoExpandTimers[node.id])
delete autoExpandTimers[node.id]
}
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null }
const draggedId = getDraggedId(e) const draggedId = getDraggedId(e)
if (!canDrop(node, draggedId)) return if (!canDrop(node, draggedId)) return
if (onDrop) onDrop(draggedId, node.id) if (onDrop) onDrop(draggedId, node.id)
} }
function handleRowClick(e, node) {
const toggleEl = e.target.closest('.tree-toggle')
const iconEl = e.target.closest('.tree-icon')
if (toggleEl || iconEl) return
if (onSelect) onSelect(node)
}
function handleRowDblClick(e, node) {
if (isContainer(node) && onToggle) onToggle(node.id)
}
function handleIconClick(e, node) {
e.stopPropagation()
if (isContainer(node) && onToggle) onToggle(node.id)
}
</script> </script>
{#each nodes as node} {#each nodes as node}
@ -81,23 +138,22 @@
draggable="true" draggable="true"
on:dragstart={(e) => handleDragStart(e, node)} on:dragstart={(e) => handleDragStart(e, node)}
on:dragover={(e) => handleDragOver(e, node)} on:dragover={(e) => handleDragOver(e, node)}
on:dragleave={(e) => handleDragLeave(e, node)}
on:drop={(e) => handleDrop(e, node)} on:drop={(e) => handleDrop(e, node)}
on:click={(e) => handleRowClick(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 isContainer(node)}
<button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)} <button class="tree-toggle" on:click|stopPropagation={() => onToggle && onToggle(node.id)}>
on:contextmenu|preventDefault={(e) => e.stopPropagation()}> <span class="tree-arrow">{expanded[node.id] ? '▾' : '▸'}</span>
{#if expanded[node.id]}
<span class="tree-arrow"></span>
{:else}
<span class="tree-arrow"></span>
{/if}
</button> </button>
{:else} {:else}
<span class="tree-toggle-placeholder"></span> <span class="tree-toggle-placeholder"></span>
{/if} {/if}
<span class="tree-icon"><TemplateIcon kind={iconKind(node)} size={16} /></span> <span class="tree-icon" on:click={(e) => handleIconClick(e, node)} on:dblclick|stopPropagation>
<span class="tree-label" on:click|stopPropagation={() => onSelect && onSelect(node)} <TemplateIcon kind={iconKind(node)} size={16} />
on:contextmenu|preventDefault={(e) => e.stopPropagation()}> </span>
<span class="tree-label" on:click|stopPropagation={() => onSelect && onSelect(node)}>
{node.title} {node.title}
</span> </span>
</div> </div>
@ -126,6 +182,11 @@
color: #fff; color: #fff;
font-weight: 500; font-weight: 500;
} }
.tree-item.drag-over {
background: #1a3a1a;
outline: 1px solid #4ade80;
outline-offset: -1px;
}
.tree-toggle { .tree-toggle {
background: none; background: none;
border: none; border: none;
@ -143,7 +204,7 @@
font-size: 12px; font-size: 12px;
} }
.tree-toggle:hover { .tree-toggle:hover {
color: #888; color: #a5b4fc;
} }
.tree-toggle-placeholder { .tree-toggle-placeholder {
display: inline-block; display: inline-block;
@ -159,6 +220,10 @@
flex-shrink: 0; flex-shrink: 0;
color: #888; color: #888;
margin-right: 4px; margin-right: 4px;
cursor: pointer;
}
.tree-icon:hover {
color: #a5b4fc;
} }
.tree-item.selected .tree-icon { .tree-item.selected .tree-icon {
color: #a5b4fc; color: #a5b4fc;