feat: expose trash in gui

This commit is contained in:
mirivlad 2026-06-05 01:05:57 +08:00
parent 035f877280
commit cc83cd3476
16 changed files with 266 additions and 6 deletions

View File

@ -19,6 +19,7 @@ func (a *App) ListSystemViews() []SystemViewDTO {
return []SystemViewDTO{
{ID: "today", Label: i18n.TF("ru", "nav.today")},
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
{ID: "trash", Label: i18n.TF("ru", "nav.trash")},
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
}

View File

@ -0,0 +1,98 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"time"
)
type TrashDTO struct {
TrashPath string `json:"trashPath"`
Nodes []TrashNodeDTO `json:"nodes"`
Entries []TrashEntryDTO `json:"entries"`
}
type TrashNodeDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
FsPath string `json:"fsPath"`
DeletedAt string `json:"deletedAt"`
}
type TrashEntryDTO struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
func (a *App) ListTrash() (*TrashDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
deleted, err := a.nodes.ListDeleted()
if err != nil {
return nil, err
}
nodes := make([]TrashNodeDTO, 0, len(deleted))
for _, n := range deleted {
deletedAt := ""
if n.DeletedAt != nil {
deletedAt = n.DeletedAt.Format(time.RFC3339)
}
nodes = append(nodes, TrashNodeDTO{
ID: n.ID,
Title: n.Title,
Type: n.Type,
FsPath: n.FsPath,
DeletedAt: deletedAt,
})
}
entries, err := listTrashEntries(trashPath)
if err != nil {
return nil, err
}
return &TrashDTO{TrashPath: trashPath, Nodes: nodes, Entries: entries}, nil
}
func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return nil, err
}
dirEntries, err := os.ReadDir(trashPath)
if err != nil {
return nil, err
}
out := make([]TrashEntryDTO, 0, len(dirEntries))
for _, entry := range dirEntries {
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, TrashEntryDTO{
Name: entry.Name(),
Path: filepath.Join(trashPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
return out, nil
}
func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil {
return err
}
trashPath := filepath.Join(a.vault, ".verstak", "trash")
if err := os.MkdirAll(trashPath, 0o750); err != nil {
return err
}
return exec.Command("xdg-open", trashPath).Run()
}

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;
}
</style>
<script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css">
<script type="module" crossorigin src="/assets/main-4y6wyoK9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-es_E5H-H.css">
</head>
<body>
<div id="app"></div>

View File

@ -0,0 +1,45 @@
package main
import (
"strings"
"testing"
)
func TestListTrashShowsDeletedNodesAndPhysicalEntries(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Trash Me", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
if err := app.DeleteNode(n.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
trash, err := app.ListTrash()
if err != nil {
t.Fatalf("ListTrash: %v", err)
}
var foundNode bool
for _, node := range trash.Nodes {
if node.ID == n.ID && node.Title == "Trash Me" && node.DeletedAt != "" {
foundNode = true
break
}
}
if !foundNode {
t.Fatalf("deleted node %s missing from trash nodes: %#v", n.ID, trash.Nodes)
}
var foundPhysical bool
for _, entry := range trash.Entries {
if strings.Contains(entry.Name, n.ID) && entry.IsDir {
foundPhysical = true
break
}
}
if !foundPhysical {
t.Fatalf("physical trash entry for %s missing: %#v", n.ID, trash.Entries)
}
}

View File

@ -92,6 +92,7 @@
let suggestions = []
let suggestionCount = 0
let inboxNodes = []
let trashInfo = null
let showCreateNode = false
let newNodeTitle = ''
let createInNode = null
@ -230,6 +231,7 @@
worklog = []
suggestions = []
inboxNodes = []
trashInfo = null
showCreateNode = false
error = ''
todayDashboard = null
@ -245,6 +247,8 @@
suggestionCount = suggestions.length
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'trash') {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '' }
} else if (id === 'journal') {
await loadJournal()
} else if (id === 'activity') {
@ -256,6 +260,7 @@
error = String(e)
todayDashboard = { cases: [] }
inboxNodes = []
trashInfo = null
activityFeed = []
}
}
@ -2023,6 +2028,57 @@
{/if}
</div>
{:else if selectedSection === 'trash'}
<div class="trash-screen">
<div class="trash-header">
<div>
<h2>{t('nav.trash')}</h2>
<p>{trashInfo?.trashPath || ''}</p>
</div>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
</div>
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
<div class="empty-state">
<p>{t('trash.empty')}</p>
</div>
{:else}
<div class="trash-grid">
<section class="trash-section">
<h3>{t('trash.deletedNodes')}</h3>
{#if (trashInfo.nodes || []).length === 0}
<p class="trash-empty-line">{t('common.empty')}</p>
{:else}
{#each trashInfo.nodes as node}
<div class="trash-row">
<div class="trash-row-main">
<span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
</div>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
</div>
{/each}
{/if}
</section>
<section class="trash-section">
<h3>{t('trash.physicalEntries')}</h3>
{#if (trashInfo.entries || []).length === 0}
<p class="trash-empty-line">{t('common.empty')}</p>
{:else}
{#each trashInfo.entries as entry}
<div class="trash-row">
<div class="trash-row-main">
<span class="trash-row-title">{entry.name}</span>
<span class="trash-row-meta">{entry.isDir ? t('mime.folder') : t('mime.file')} · {formatDate(entry.modifiedAt)}</span>
</div>
<span class="trash-row-path">{entry.path}</span>
</div>
{/each}
{/if}
</section>
</div>
{/if}
</div>
{:else if selectedSection === 'journal'}
<div class="journal-screen">
<div class="journal-header">
@ -2742,6 +2798,21 @@
.inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* Trash screen */
.trash-screen { padding: 24px; overflow-y: auto; flex: 1; }
.trash-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.trash-header h2 { margin: 0 0 6px; }
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.trash-section { min-width: 0; }
.trash-section h3 { margin: 0 0 10px; font-size: 13px; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; }
.trash-row { padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row-main { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { flex-shrink: 0; color: #8888a0; font-size: 12px; }
.trash-row-path { display: block; margin-top: 4px; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-header { margin-bottom: 24px; }

View File

@ -1,6 +1,7 @@
export default {
'nav.today': 'Today',
'nav.inbox': 'Inbox',
'nav.trash': 'Trash',
'nav.activity': 'Activity',
'nav.journal': 'Journal',
'nav.clients': 'Clients',
@ -25,6 +26,10 @@ export default {
'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Root items without an assigned section',
'inbox.empty': 'No unprocessed items',
'trash.openFolder': 'Open trash folder',
'trash.empty': 'Trash is empty',
'trash.deletedNodes': 'Deleted items',
'trash.physicalEntries': 'Files in .verstak/trash',
'tab.overview': 'Overview',
'tab.notes': 'Notes',
'tab.files': 'Files',

View File

@ -1,6 +1,7 @@
export default {
'nav.today': 'Сегодня',
'nav.inbox': 'Неразобранное',
'nav.trash': 'Корзина',
'nav.activity': 'Активность',
'nav.journal': 'Журнал',
'nav.clients': 'Клиенты',
@ -27,6 +28,11 @@ export default {
'inbox.subtitle': 'Корневые элементы без назначенного раздела',
'inbox.empty': 'Неразобранных элементов нет',
'trash.openFolder': 'Открыть папку корзины',
'trash.empty': 'Корзина пуста',
'trash.deletedNodes': 'Удаленные элементы',
'trash.physicalEntries': 'Файлы в .verstak/trash',
'tab.overview': 'Обзор',
'tab.notes': 'Заметки',
'tab.files': 'Файлы',

View File

@ -82,6 +82,14 @@ export function ListInboxNodes() {
return window['go']['main']['App']['ListInboxNodes']();
}
export function ListTrash() {
return window['go']['main']['App']['ListTrash']();
}
export function OpenTrashFolder() {
return window['go']['main']['App']['OpenTrashFolder']();
}
export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
}

View File

@ -186,6 +186,19 @@ func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) {
return scanNodes(rows)
}
// ListDeleted returns soft-deleted nodes, newest deleted first.
func (r *Repository) ListDeleted() ([]Node, error) {
rows, err := r.db.Query(
`SELECT ` + nodeColumns + ` FROM nodes
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC, updated_at DESC, title`)
if err != nil {
return nil, err
}
defer rows.Close()
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) {

View File

@ -1,6 +1,7 @@
{
"nav.today": "Today",
"nav.inbox": "Inbox",
"nav.trash": "Trash",
"nav.activity": "Activity",
"nav.clients": "Clients",
"nav.projects": "Projects",

View File

@ -1,6 +1,7 @@
{
"nav.today": "Сегодня",
"nav.inbox": "Неразобранное",
"nav.trash": "Корзина",
"nav.activity": "Активность",
"nav.clients": "Клиенты",
"nav.projects": "Проекты",

View File

@ -139,6 +139,10 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.inbox-item-actions .btn', 'Открыть')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
await clickText(cdp, '.nav-item', 'Корзина')
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible')
await assertText(cdp, 'node-trash_Trash-Smoke-Folder', 'trash: physical entry visible')
await clickText(cdp, '.tree-label', 'Smoke Project')
await waitForSelector(cdp, '.tabs')
await assertText(cdp, 'Smoke Project', 'node: selected project visible')
@ -673,12 +677,19 @@ function wailsMockSource() {
ListSystemViews: async () => [
{ id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' },
{ id: 'trash', label: 'Корзина' },
{ id: 'activity', label: 'Активность' },
{ id: 'journal', label: 'Журнал' },
],
ListWorkspaceTree: async () => clone(state.nodes),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && (!node.section || node.section === 'inbox'))),
ListTrash: async () => clone({
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],
entries: [{ name: 'node-trash_Trash-Smoke-Folder', path: '/tmp/verstak-smoke-vault/.verstak/trash/node-trash_Trash-Smoke-Folder', isDir: true, size: 0, modifiedAt: now }],
}),
OpenTrashFolder: async () => true,
ListEnabledTemplates: async () => clone(templates),
AllTemplates: async () => clone(templates),
SetTemplateEnabled: async () => true,