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:
parent
5257789a4d
commit
64e6c6f735
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue