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{ return []SystemViewDTO{
{ID: "today", Label: i18n.TF("ru", "nav.today")}, {ID: "today", Label: i18n.TF("ru", "nav.today")},
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")}, {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: "journal", Label: i18n.TF("ru", "nav.journal")},
{ID: "activity", Label: i18n.TF("ru", "nav.activity")}, {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; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script> <script type="module" crossorigin src="/assets/main-4y6wyoK9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css"> <link rel="stylesheet" crossorigin href="/assets/main-es_E5H-H.css">
</head> </head>
<body> <body>
<div id="app"></div> <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 suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let inboxNodes = [] let inboxNodes = []
let trashInfo = null
let showCreateNode = false let showCreateNode = false
let newNodeTitle = '' let newNodeTitle = ''
let createInNode = null let createInNode = null
@ -230,6 +231,7 @@
worklog = [] worklog = []
suggestions = [] suggestions = []
inboxNodes = [] inboxNodes = []
trashInfo = null
showCreateNode = false showCreateNode = false
error = '' error = ''
todayDashboard = null todayDashboard = null
@ -245,6 +247,8 @@
suggestionCount = suggestions.length suggestionCount = suggestions.length
} else if (id === 'inbox') { } else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || [] inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'trash') {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '' }
} else if (id === 'journal') { } else if (id === 'journal') {
await loadJournal() await loadJournal()
} else if (id === 'activity') { } else if (id === 'activity') {
@ -256,6 +260,7 @@
error = String(e) error = String(e)
todayDashboard = { cases: [] } todayDashboard = { cases: [] }
inboxNodes = [] inboxNodes = []
trashInfo = null
activityFeed = [] activityFeed = []
} }
} }
@ -2023,6 +2028,57 @@
{/if} {/if}
</div> </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'} {:else if selectedSection === 'journal'}
<div class="journal-screen"> <div class="journal-screen">
<div class="journal-header"> <div class="journal-header">
@ -2742,6 +2798,21 @@
.inbox-item-meta { color: #8888a0; font-size: 12px; } .inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; } .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 */
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; } .journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-header { margin-bottom: 24px; } .journal-header { margin-bottom: 24px; }

View File

@ -1,6 +1,7 @@
export default { export default {
'nav.today': 'Today', 'nav.today': 'Today',
'nav.inbox': 'Inbox', 'nav.inbox': 'Inbox',
'nav.trash': 'Trash',
'nav.activity': 'Activity', 'nav.activity': 'Activity',
'nav.journal': 'Journal', 'nav.journal': 'Journal',
'nav.clients': 'Clients', 'nav.clients': 'Clients',
@ -25,6 +26,10 @@ export default {
'nav.moveToRoot': 'Move to root', 'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Root items without an assigned section', 'inbox.subtitle': 'Root items without an assigned section',
'inbox.empty': 'No unprocessed items', '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.overview': 'Overview',
'tab.notes': 'Notes', 'tab.notes': 'Notes',
'tab.files': 'Files', 'tab.files': 'Files',

View File

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

View File

@ -82,6 +82,14 @@ export function ListInboxNodes() {
return window['go']['main']['App']['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) { export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['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) 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, // CountChildren returns the number of non-deleted children for a parent,
// optionally filtered by one or more types. // optionally filtered by one or more types.
func (r *Repository) CountChildren(parentID string, types ...string) (int, error) { func (r *Repository) CountChildren(parentID string, types ...string) (int, error) {

View File

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

View File

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

View File

@ -139,6 +139,10 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.inbox-item-actions .btn', 'Открыть') await clickText(cdp, '.inbox-item-actions .btn', 'Открыть')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list') 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 clickText(cdp, '.tree-label', 'Smoke Project')
await waitForSelector(cdp, '.tabs') await waitForSelector(cdp, '.tabs')
await assertText(cdp, 'Smoke Project', 'node: selected project visible') await assertText(cdp, 'Smoke Project', 'node: selected project visible')
@ -673,12 +677,19 @@ function wailsMockSource() {
ListSystemViews: async () => [ ListSystemViews: async () => [
{ id: 'today', label: 'Сегодня' }, { id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' }, { id: 'inbox', label: 'Неразобранное' },
{ id: 'trash', label: 'Корзина' },
{ id: 'activity', label: 'Активность' }, { id: 'activity', label: 'Активность' },
{ id: 'journal', label: 'Журнал' }, { id: 'journal', label: 'Журнал' },
], ],
ListWorkspaceTree: async () => clone(state.nodes), ListWorkspaceTree: async () => clone(state.nodes),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)), ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && (!node.section || node.section === 'inbox'))), 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), ListEnabledTemplates: async () => clone(templates),
AllTemplates: async () => clone(templates), AllTemplates: async () => clone(templates),
SetTemplateEnabled: async () => true, SetTemplateEnabled: async () => true,