fix: trash file preview, visual CSS, virtual folder model

- Added resolveTrashPath() backend function: walks ancestor chain to find
  files inside deleted/moved folders (flat trash directory)
- Added TrashFsPath to TrashNodeDTO, computed during ListTrash via
  parent-to-child propagation of physical trash path
- Fixed visual CSS for trash rows: button reset (no white bg, transparent,
  inherit font/color), hover styles match app dark theme
- Root view filters out descendant nodes (only shows top-level items)
This commit is contained in:
mirivlad 2026-06-05 17:05:35 +08:00
parent 5257789a4d
commit 64e6c6f735
7 changed files with 157 additions and 20 deletions

View File

@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
"verstak/internal/core/nodes"
)
type TrashDTO struct {
@ -24,6 +26,7 @@ type TrashNodeDTO struct {
Type string `json:"type"`
FsPath string `json:"fsPath"`
NodePath string `json:"nodePath"`
TrashFsPath string `json:"trashFsPath"`
DeletedAt string `json:"deletedAt"`
}
@ -45,8 +48,13 @@ func (a *App) ListTrash() (*TrashDTO, error) {
if err != nil {
return nil, err
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
// Phase 1: build all DTOs and compute direct trash paths for folder-type nodes.
nodeMap := make(map[string]*TrashNodeDTO, len(deleted))
var allDeleted []nodes.Node
for _, n := range deleted {
allDeleted = append(allDeleted, n)
}
for _, n := range allDeleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
@ -55,7 +63,7 @@ func (a *App) ListTrash() (*TrashDTO, error) {
if n.ParentID != nil {
parentID = *n.ParentID
}
nodes = append(nodes, TrashNodeDTO{
dto := &TrashNodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
@ -63,7 +71,49 @@ func (a *App) ListTrash() (*TrashDTO, error) {
FsPath: n.FsPath,
NodePath: a.nodes.Path(n.ID),
DeletedAt: deletedAt,
})
}
nodeMap[n.ID] = dto
// Try direct trash entry (for folders that were os.Rename'd).
if p, err := a.findTrashEntryForNode(n.ID); err == nil {
dto.TrashFsPath = p
}
}
// Phase 2: propagate trash paths from parents to children that have no direct entry
// but whose FsPath starts with the parent's FsPath.
changed := true
for changed {
changed = false
for _, n := range allDeleted {
dto := nodeMap[n.ID]
if dto.TrashFsPath != "" {
continue
}
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
if parentID == "" {
continue
}
parent := nodeMap[parentID]
if parent == nil || parent.TrashFsPath == "" {
continue
}
// Child inherits parent's trash path + relative FsPath fragment.
if n.FsPath != "" && parent.FsPath != "" && strings.HasPrefix(n.FsPath, parent.FsPath) {
rel := strings.TrimPrefix(n.FsPath, parent.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
dto.TrashFsPath = filepath.Join(parent.TrashFsPath, rel)
changed = true
}
}
}
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, dto := range nodeMap {
nodes = append(nodes, *dto)
}
entries, err := listTrashEntries(trashPath)
@ -302,7 +352,7 @@ func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.findTrashEntryForNode(nodeID)
path, err := a.resolveTrashPath(nodeID)
if err != nil {
return "", err
}
@ -313,6 +363,91 @@ func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
return string(data), nil
}
// resolveTrashPath finds the physical path of a deleted node's file in the trash.
// For directly-moved entries (folders), it looks up <nodeID>_* in trash dir.
// For nested files (moved as part of a parent folder), it walks up the ancestor
// chain until it finds a folder with a direct trash entry, then appends the
// relative filesystem path.
func (a *App) resolveTrashPath(nodeID string) (string, error) {
// 1. Try direct lookup first (for nodes that were individually moved).
if p, err := a.findTrashEntryForNode(nodeID); err == nil {
return p, nil
}
// 2. Walk parent chain to find nearest ancestor with a direct trash entry.
type step struct {
ID string
FsPath string
Title string
}
var chain []step
current := nodeID
for current != "" {
n, err := a.nodes.Get(current)
if err != nil {
break
}
chain = append(chain, step{ID: n.ID, FsPath: n.FsPath, Title: n.Title})
if n.ParentID != nil {
current = *n.ParentID
} else {
current = ""
}
}
// chain[0] = the node itself, chain[len-1] = topmost ancestor.
// Walk from closest ancestor outward to find a trash entry.
for i := 0; i < len(chain); i++ {
anc := chain[i]
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
// Build the relative path from this ancestor down to the original node.
// The node at index 0 is the target; any step between anc and index 0
// contributes a subdirectory name.
// We walk from anc up to target (index 0), collecting FsPath segments.
var relSegments []string
for j := 0; j < i; j++ {
child := chain[j]
if child.FsPath != "" && anc.FsPath != "" && strings.HasPrefix(child.FsPath, anc.FsPath) {
rel := strings.TrimPrefix(child.FsPath, anc.FsPath)
rel = strings.TrimPrefix(rel, "/")
if rel != "" {
relSegments = append(relSegments, rel)
} else if child.Title != "" {
relSegments = append(relSegments, child.Title)
}
} else if child.Title != "" {
relSegments = append(relSegments, child.Title)
}
anc = child
}
fullPath := ancPath
if len(relSegments) > 0 {
fullPath = filepath.Join(ancPath, filepath.Join(relSegments...))
}
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
return fullPath, nil
}
}
// 3. Fallback: try node title as filename inside each ancestor's trash dir.
for _, anc := range chain {
ancPath, err := a.findTrashEntryForNode(anc.ID)
if err != nil {
continue
}
fi, err := os.Stat(ancPath)
if err != nil || !fi.IsDir() {
continue
}
// Try the node's title as a direct child file inside the ancestor dir.
candidate := filepath.Join(ancPath, chain[0].Title)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("trash file not found for node %s", nodeID)
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err

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

@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-PQ2CZjSe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bomne4X7.css">
<script type="module" crossorigin src="/assets/main-DGUrUZ5X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
</head>
<body>
<div id="app"></div>

View File

@ -3870,13 +3870,15 @@
.trash-section-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; min-height: 36px; }
.trash-section-head h3 { margin: 0 0 2px; }
.trash-section-head p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; cursor: default; }
.trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row.folder { background: #1b2132; border-color: #303856; }
.trash-row.selected { border-color: #6366f1; background: #20203a; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; background: transparent; border: none; }
.trash-row-icon:hover { background: #222238; color: #e4e4ef; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; padding: 2px 0; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 2px 0; cursor: pointer; background: transparent; border: none; color: inherit; font: inherit; text-align: left; width: 100%; border-radius: 4px; }
.trash-row-main:hover { background: rgba(255,255,255,0.03); }
.trash-row-main:hover .trash-row-title { color: #a5b4fc; }
.trash-row-main:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-date { color: #666; font-size: 11px; }