fix: stabilize trash navigation and action icons

This commit is contained in:
mirivlad 2026-06-05 14:41:40 +08:00
parent 1fa009b1e2
commit c8aaf36533
11 changed files with 399 additions and 99 deletions

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-mEr4zvhI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-SjC7TazH.css">
<script type="module" crossorigin src="/assets/main-DzZpCe3r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BoZGEeiu.css">
</head>
<body>
<div id="app"></div>

View File

@ -82,6 +82,46 @@ func TestRestoreTrashNodeRestoresAncestorPathOnlyForSelectedChild(t *testing.T)
}
}
func TestRestoreTrashNodeFromNestedDeletedFolderRestoresFullPath(t *testing.T) {
app, vault := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Verstak", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
templates, err := app.CreateNodeFromTemplate(parent.ID, "templates", "folder.default")
if err != nil {
t.Fatalf("create templates: %v", err)
}
registry, err := app.CreateNodeFromTemplate(templates.ID, "registry.go", "folder.default")
if err != nil {
t.Fatalf("create registry: %v", err)
}
other, err := app.CreateNodeFromTemplate(templates.ID, "other.go", "folder.default")
if err != nil {
t.Fatalf("create other: %v", err)
}
if err := app.DeleteNode(parent.ID); err != nil {
t.Fatalf("DeleteNode: %v", err)
}
if err := app.RestoreTrashNode(registry.ID); err != nil {
t.Fatalf("RestoreTrashNode(registry): %v", err)
}
for _, id := range []string{parent.ID, templates.ID, registry.ID} {
if _, err := app.nodes.GetActive(id); err != nil {
t.Fatalf("node %s should be active after restore: %v", id, err)
}
}
if _, err := app.nodes.GetActive(other.ID); err == nil {
t.Fatalf("unselected nested sibling should remain deleted")
}
if _, err := os.Stat(filepath.Join(vault, "Verstak", "templates", "registry.go")); err != nil {
t.Fatalf("restored nested path missing: %v", err)
}
}
func TestTrashCountPurgeAndEmpty(t *testing.T) {
app, _ := setupTestApp(t)
a, _ := app.CreateNodeFromTemplate("", "Trash A", "folder.default")

View File

@ -10,6 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte'
import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n'
@ -105,6 +106,12 @@
let trashCount = 0
let trashSelectedIds = []
let trashFolderId = ''
let trashFolderStack = []
let visibleTrashNodes = []
let currentTrashPath = ''
let currentTrashTitle = ''
let navHistory = []
let restoringHistory = false
let showCreateNode = false
let newNodeTitle = ''
let createInNode = null
@ -189,6 +196,104 @@
let unlistenDrop = null
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId)
$: currentTrashPath = trashFolderStack.length === 0 ? t('nav.trash') : [t('nav.trash'), ...trashFolderStack.map(node => node.title)].join(' / ')
$: currentTrashTitle = currentTrashFolder()?.title || t('trash.deletedNodes')
function currentNavigationSnapshot() {
return {
section: selectedSection || '',
nodeId: selectedNode?.id || '',
tab: selectedNode ? activeTab : '',
trashFolderId: selectedSection === 'trash' ? trashFolderId : '',
trashFolderStack: selectedSection === 'trash' ? trashFolderStack.map(item => ({ id: item.id, title: item.title })) : [],
}
}
function sameNavigation(a, b) {
return a.section === b.section && a.nodeId === b.nodeId && a.tab === b.tab && a.trashFolderId === b.trashFolderId
}
function rememberNavigation() {
if (restoringHistory) return
const snapshot = currentNavigationSnapshot()
const last = navHistory[navHistory.length - 1]
if (!last || !sameNavigation(last, snapshot)) {
navHistory = [...navHistory, snapshot].slice(-80)
}
}
function resetTrashBrowser() {
trashFolderId = ''
trashFolderStack = []
trashSelectedIds = []
}
function closeTopModalForBack() {
if (showConfirm) { closeConfirm(); return true }
if (showSettings) { closeSettings(); return true }
if (previewItem) { closePreview(); return true }
if (assignInboxItem) { closeAssignInbox(); return true }
if (editingLink) { closeEditLink(); return true }
if (showRename) { showRename = false; return true }
if (showWorklogModal) { closeWorklogModal(); return true }
if (showImportDialog) { cancelImport(); return true }
if (showCreateAction) { cancelCreateAction(); return true }
if (showCreateNote) { cancelCreateNote(); return true }
if (showCreateNode) { cancelCreateNode(); return true }
if (contextMenu.visible) { closeContextMenu(); return true }
if (noteEditor) { closeNoteEditor(); return true }
return false
}
async function restoreNavigation(snapshot) {
restoringHistory = true
try {
if (snapshot.nodeId) {
const node = await wailsCall('GetNodeDetail', snapshot.nodeId)
if (node) {
await selectNode(node)
activeTab = snapshot.tab || 'overview'
if (activeTab === 'files' && fileItems.length === 0) {
await loadFolder(node.id)
}
}
} else if (snapshot.section) {
await selectSystemView(snapshot.section)
if (snapshot.section === 'trash') {
trashFolderId = snapshot.trashFolderId || ''
trashFolderStack = snapshot.trashFolderStack || []
trashSelectedIds = []
await refreshTrash()
}
}
} finally {
restoringHistory = false
}
}
async function goBack() {
if (closeTopModalForBack()) return
const snapshot = navHistory[navHistory.length - 1]
if (!snapshot) return
navHistory = navHistory.slice(0, -1)
await restoreNavigation(snapshot)
}
function handleAuxClick(e) {
if (e.button !== 3) return
if (isEditableTarget(e.target)) return
e.preventDefault()
goBack()
}
function setActiveTab(tabId) {
if (activeTab === tabId) return
rememberNavigation()
activeTab = tabId
if (tabId === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id)
}
// ===== Lifecycle =====
onMount(async () => {
// Check startup status
@ -237,6 +342,7 @@
window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('drop', handleGlobalDrop)
window.addEventListener('auxclick', handleAuxClick)
loading = false
loadSyncStatus()
@ -250,10 +356,12 @@
window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('drop', handleGlobalDrop)
window.removeEventListener('auxclick', handleAuxClick)
})
// ===== System view / Node selection =====
async function selectSystemView(id) {
rememberNavigation()
selectedSection = id
selectedNode = null
activeTab = 'overview'
@ -266,6 +374,7 @@
suggestions = []
inboxNodes = []
trashInfo = null
resetTrashBrowser()
showCreateNode = false
error = ''
todayDashboard = null
@ -300,7 +409,9 @@
}
async function selectNode(node) {
rememberNavigation()
selectedNode = node
selectedSection = ''
activeTab = 'overview'
notes = []
files = []
@ -314,6 +425,7 @@
previewContent = ''
selectedIds = []
dragIds = []
resetTrashBrowser()
noteEditor = null
showCreateNode = false
showCreateNote = false
@ -365,6 +477,7 @@
async function navigateToFolder(folderId) {
if (!selectedNode) return
rememberNavigation()
try {
const node = await wailsCall('GetNodeDetail', folderId)
if (node) {
@ -382,6 +495,7 @@
}
function navigateBack() {
rememberNavigation()
if (folderStack.length < 2) {
// Go back to root
folderStack = []
@ -611,6 +725,12 @@
function handleKeydown(e) {
if (isEditableTarget(e.target)) return
if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowLeft')) {
e.preventDefault()
goBack()
return
}
if (activeTab !== 'files') return
if (e.ctrlKey || e.metaKey) {
@ -623,15 +743,9 @@
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete' || e.key === 'Backspace') {
} else if (e.key === 'Delete') {
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
// Backspace without selection → navigate up
if (e.key === 'Backspace' && folderStack.length > 0) {
e.preventDefault()
navigateBack()
return
}
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
@ -940,6 +1054,7 @@
}
if (kind === 'folder' || item.type === 'folder') {
rememberNavigation()
selectedSection = ''
selectedNode = item
activeTab = 'files'
@ -1176,9 +1291,13 @@
async function refreshTrash() {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '', count: 0 }
trashCount = trashInfo.count || 0
trashSelectedIds = trashSelectedIds.filter(id => (trashInfo.nodes || []).some(node => node.id === id))
if (trashFolderId && !(trashInfo.nodes || []).some(node => node.id === trashFolderId)) {
trashFolderId = ''
const nodes = trashInfo.nodes || []
trashSelectedIds = trashSelectedIds.filter(id => nodes.some(node => node.id === id))
if (trashFolderId && !nodes.some(node => node.id === trashFolderId)) {
resetTrashBrowser()
} else if (trashFolderStack.length > 0) {
trashFolderStack = trashFolderStack.filter(item => nodes.some(node => node.id === item.id))
trashFolderId = trashFolderStack.length > 0 ? trashFolderStack[trashFolderStack.length - 1].id : ''
}
}
@ -1186,10 +1305,39 @@
try { trashCount = await wailsCall('TrashCount') || 0 } catch (e) { trashCount = 0 }
}
function trashVisibleNodes() {
const nodes = trashInfo?.nodes || []
if (!trashFolderId) return nodes.filter(node => !node.parentId || !nodes.some(other => other.id === node.parentId))
return nodes.filter(node => node.parentId === trashFolderId)
function computeVisibleTrashNodes(nodes, folderId) {
if (!folderId) return nodes.filter(node => !trashParentId(node) || !nodes.some(other => other.id === trashParentId(node)))
return nodes.filter(node => trashParentId(node) === folderId)
}
function trashParentId(node) {
return node?.parentId || node?.parentID || node?.parent_id || ''
}
function currentTrashFolder() {
if (!trashFolderId) return null
return (trashInfo?.nodes || []).find(node => node.id === trashFolderId) || trashFolderStack[trashFolderStack.length - 1] || null
}
function openTrashFolderNode(node) {
rememberNavigation()
trashFolderId = node.id
const existingIndex = trashFolderStack.findIndex(item => item.id === node.id)
if (existingIndex >= 0) {
trashFolderStack = trashFolderStack.slice(0, existingIndex + 1)
} else {
trashFolderStack = [...trashFolderStack, { id: node.id, title: node.title }]
}
trashSelectedIds = []
}
function trashBack() {
if (trashFolderStack.length === 0) return
rememberNavigation()
const nextStack = trashFolderStack.slice(0, -1)
trashFolderStack = nextStack
trashFolderId = nextStack.length > 0 ? nextStack[nextStack.length - 1].id : ''
trashSelectedIds = []
}
function toggleTrashSelection(id) {
@ -2044,14 +2192,14 @@
if (!node) return
selectNode(node)
if (target.tab === 'notes') {
activeTab = 'notes'
setActiveTab('notes')
if (target.targetId) {
try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {}
const note = notes.find(n => n.id === target.targetId)
if (note) setTimeout(() => openNote(note), 100)
}
} else if (target.tab === 'files') {
activeTab = 'files'
setActiveTab('files')
if (target.targetId) {
// targetId is the node ID of the file/folder in the tree.
// Navigate to its parent folder so the user sees context.
@ -2284,7 +2432,7 @@
{#if tab.id === 'activity'}
<span class="tab-separator" aria-hidden="true"></span>
{/if}
<button class="tab" class:active={activeTab === tab.id} on:click={() => { activeTab = tab.id; if (tab.id === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id) }}>{tab.label}</button>
<button class="tab" class:active={activeTab === tab.id} on:click={() => setActiveTab(tab.id)}>{tab.label}</button>
{/each}
</div>
<div class="tab-content">
@ -2297,11 +2445,11 @@
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div>
<div class="quick-actions">
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
<button class="qa-btn" on:click={() => { setActiveTab('notes'); openCreateNote() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{t('overview.newNote')}
</button>
<button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
<button class="qa-btn" on:click={() => { setActiveTab('files'); addFile() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{t('overview.addFile')}
</button>
@ -2309,7 +2457,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{t('overview.addAction')}
</button>
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>
<button class="qa-btn" on:click={() => setActiveTab('worklog')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('overview.logTime')}
</button>
@ -2460,13 +2608,23 @@
</div>
<div class="inbox-item-actions">
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
{@html actionIcon('attach')}
</button>
<button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3h7v7"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>
{@html actionIcon('move')}
</button>
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click|stopPropagation={() => openInboxArtifact(item)}>
{@html actionIcon('open')}
</button>
{#if canShowInboxArtifactInFolder(item)}
<button class="inbox-icon-btn" title={t('file.showInExplorer')} aria-label={t('file.showInExplorer')} on:click|stopPropagation={() => openNodeFolder(item)}>
{@html actionIcon('showInExplorer')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
{@html actionIcon('delete')}
</button>
</div>
</div>
@ -2543,9 +2701,10 @@
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<div class="suggestion-info">
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>
{t('suggest.edit')}
</button>
@ -2557,7 +2716,7 @@
</button>
</div>
</div>
{#if s.events && s.events.length > 0}
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
@ -2700,20 +2859,26 @@
<div class="inbox-item-actions">
{#if item.suggestedTargetNodeId}
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
{@html actionIcon('attach')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3h7v7"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>
{@html actionIcon('move')}
</button>
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click|stopPropagation={() => openInboxArtifact(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
{@html actionIcon('open')}
</button>
{#if canShowInboxArtifactInFolder(item)}
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
<button class="inbox-icon-btn" title={t('file.showInExplorer')} aria-label={t('file.showInExplorer')} on:click|stopPropagation={() => openNodeFolder(item)}>
{@html actionIcon('showInExplorer')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
{@html actionIcon('delete')}
</button>
</div>
</div>
@ -2727,66 +2892,69 @@
<div class="trash-header">
<div>
<h2>{t('nav.trash')}</h2>
<p>{trashInfo?.trashPath || ''}</p>
<p>{currentTrashPath}</p>
</div>
<div class="trash-actions">
{#if trashSelectedIds.length > 0}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{t('trash.restore')} ({trashSelectedIds.length})</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{t('common.delete')} ({trashSelectedIds.length})</button>
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{@html actionIcon('restore', 14)} {t('trash.restore')} ({trashSelectedIds.length})</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{@html actionIcon('purge', 14)} {t('common.delete')} ({trashSelectedIds.length})</button>
{/if}
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{t('trash.emptyTrash')}</button>
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{@html actionIcon('emptyTrash', 14)} {t('trash.emptyTrash')}</button>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
</div>
</div>
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
{#if !trashInfo || (trashInfo.nodes || []).length === 0}
<div class="empty-state">
<p>{t('trash.empty')}</p>
</div>
{:else}
<div class="trash-grid">
<div class="trash-browser">
<section class="trash-section">
<h3>{t('trash.deletedNodes')}</h3>
{#if (trashInfo.nodes || []).length === 0}
<div class="trash-section-head">
{#if trashFolderId}
<button class="inbox-icon-btn" title={t('common.backLabel')} aria-label={t('common.backLabel')} on:click={trashBack}>
{@html actionIcon('back')}
</button>
{/if}
<div>
<h3>{currentTrashTitle}</h3>
<p>{currentTrashPath}</p>
</div>
</div>
{#if visibleTrashNodes.length === 0}
<p class="trash-empty-line">{t('common.empty')}</p>
{:else}
{#if trashFolderId}
<button class="btn btn-sm back-btn" on:click={() => { trashFolderId = ''; trashSelectedIds = [] }}>{t('common.backLabel')}</button>
{/if}
{#each trashVisibleNodes() as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)}>
{#each visibleTrashNodes as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)} class:folder={node.type !== 'file' && node.type !== 'note'}>
<input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} />
<span class="trash-row-icon" aria-hidden="true">
{@html actionIcon(node.type !== 'file' && node.type !== 'note' ? 'folder' : 'open')}
</span>
<div class="trash-row-main">
<span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)}</span>
<span class="trash-row-meta">{formatDate(node.deletedAt)}</span>
</div>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
<div class="trash-row-actions">
{#if node.type !== 'file' && node.type !== 'note'}
<button class="btn btn-sm" on:click={() => { trashFolderId = node.id; trashSelectedIds = [] }}>{t('common.open')}</button>
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click={() => openTrashFolderNode(node)}>
{@html actionIcon('open')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectionOr(node.id))}>{t('trash.restore')}</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectionOr(node.id))}>{t('common.delete')}</button>
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('trash.restore')} aria-label={t('trash.restore')} on:click={() => restoreTrash(trashSelectionOr(node.id))}>
{@html actionIcon('restore')}
</button>
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click={() => purgeTrash(trashSelectionOr(node.id))}>
{@html actionIcon('purge')}
</button>
</div>
</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>
@ -2874,9 +3042,10 @@
<div class="suggestion-info">
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
@ -2885,7 +3054,7 @@
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div>
{#if s.events && s.events.length > 0}
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
@ -3035,9 +3204,10 @@
<div class="suggestion-info">
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
@ -3046,7 +3216,7 @@
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div>
{#if s.events && s.events.length > 0}
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
@ -3555,29 +3725,29 @@
.suggestions-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; cursor: pointer; gap: 10px; min-height: 38px; }
.suggestion-card.expanded { border-color: #3a3a5c; }
.suggestion-detail { width: 100%; padding: 8px 12px 10px; border-top: 1px solid #2a2a3c; }
.suggestion-detail-title { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 0 4px; }
.suggestion-detail-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; color: #b0b0c0; }
.suggestion-detail { width: 100%; padding: 6px 10px 8px; border-top: 1px solid #2a2a3c; }
.suggestion-detail-title { font-size: 10px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 4px 0; }
.suggestion-detail-event { display: flex; align-items: center; gap: 7px; padding: 2px 0; font-size: 12px; color: #b0b0c0; }
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Suggestion cards */
.suggestion-card { display: flex; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 0; border: 1px solid transparent; }
.suggestion-card { display: flex; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 6px; gap: 0; border: 1px solid transparent; }
.suggestion-card:last-child { margin-bottom: 0; }
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
.suggestion-node:hover { text-decoration: underline; }
.suggestion-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.suggestion-min-input { width: 60px; padding: 4px 8px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; text-align: center; }
.suggestion-min-input { width: 54px; padding: 3px 7px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; text-align: center; }
.suggestion-min-label { font-size: 12px; color: #8888a0; }
.suggestion-confidence { font-size: 11px; padding: 2px 6px; border-radius: 3px; }
.suggestion-confidence.low { color: #fbbf24; }
.suggestion-confidence.medium { color: #60a5fa; }
.suggestion-confidence.high { color: #34d399; }
.suggestion-confidence-dot { width: 9px; height: 9px; border-radius: 999px; background: #77798a; box-shadow: 0 0 0 3px rgba(119,121,138,0.12); flex: 0 0 auto; }
.suggestion-confidence-dot.low { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.12); }
.suggestion-confidence-dot.medium { background: #fbbf24; box-shadow: 0 0 0 3px rgba(251,191,36,0.12); }
.suggestion-confidence-dot.high { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.12); }
/* Inbox screen */
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
@ -3595,6 +3765,7 @@
.inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
.inbox-icon-btn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #2a2a3c; border-radius: 6px; background: #13131f; color: #a0a0b8; cursor: pointer; transition: color 0.12s, border-color 0.12s, background 0.12s; }
.inbox-icon-slot { width: 30px; height: 30px; display: inline-block; flex: 0 0 30px; }
.inbox-icon-btn:hover { color: #e4e4ef; border-color: #3a3a5c; background: #222238; }
.inbox-icon-btn-primary { color: #a5b4fc; border-color: #34346a; }
.inbox-icon-btn-primary:hover { color: #fff; border-color: #6366f1; background: #272750; }
@ -3621,16 +3792,21 @@
.trash-header h2 { margin: 0 0 6px; }
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.trash-browser { max-width: 980px; }
.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 { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.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) minmax(120px, 220px) 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-main { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.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 { grid-column: 2 / -1; display: block; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-row-meta { color: #8888a0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-path { color: #6f7390; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-actions { display: flex; align-items: center; gap: 6px; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */
@ -3736,7 +3912,8 @@
.assign-status { color: #8888a0; font-size: 12px; }
/* Buttons */
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn :global(svg), .inbox-icon-btn :global(svg) { flex-shrink: 0; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }

View File

@ -0,0 +1,16 @@
const actionIcons = {
open: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
showInExplorer: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h8.5A2.5 2.5 0 0 1 22 8.5v8A2.5 2.5 0 0 1 19.5 19h-15A2.5 2.5 0 0 1 2 16.5v-10"/><path d="M12 11h6"/><path d="m15 8 3 3-3 3"/>',
delete: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>',
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/><path d="M12 12v5"/><path d="m9 14 3-3 3 3"/>',
purge: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m9 11 6 6"/><path d="m15 11-6 6"/><path d="M6 6l1 14h10l1-14"/>',
move: '<path d="M5 9V5h4"/><path d="m5 5 6 6"/><path d="M19 15v4h-4"/><path d="m19 19-6-6"/><path d="M19 5h-4"/><path d="M19 5v4"/><path d="m14 10 5-5"/><path d="M5 19h4"/><path d="M5 19v-4"/><path d="m10 14-5 5"/>',
attach: '<path d="M21.44 11.05 12 20.49a6 6 0 0 1-8.49-8.49l9.44-9.44a4 4 0 1 1 5.66 5.66L9.17 17.66a2 2 0 1 1-2.83-2.83l8.49-8.48"/>',
back: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
emptyTrash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/><path d="M10 11v5"/><path d="M14 11v5"/>',
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h8.5A2.5 2.5 0 0 1 22 8.5v8A2.5 2.5 0 0 1 19.5 19h-15A2.5 2.5 0 0 1 2 16.5v-10"/>',
}
export function actionIcon(name, size = 16) {
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${actionIcons[name] || ''}</svg>`
}

View File

@ -31,7 +31,7 @@ export default {
'inbox.clipboardEmpty': 'Clipboard is empty',
'inbox.clipboardUnavailable': 'Clipboard is unavailable',
'inbox.assign': 'Assign',
'inbox.keepHere': 'Keep here',
'inbox.keepHere': 'Attach at captured location',
'inbox.localEmpty': 'No unprocessed materials for this case',
'inbox.capturedIn': 'Captured in',
'inbox.suggestedTarget': 'Target',
@ -58,7 +58,6 @@ export default {
'trash.emptyTrash': 'Empty trash',
'trash.restore': 'Restore',
'trash.deletedNodes': 'Deleted items',
'trash.physicalEntries': 'Files in .verstak/trash',
'tab.overview': 'Overview',
'tab.notes': 'Notes',
'tab.files': 'Files',

View File

@ -32,7 +32,7 @@ export default {
'inbox.clipboardEmpty': 'Буфер обмена пуст',
'inbox.clipboardUnavailable': 'Буфер обмена недоступен',
'inbox.assign': 'Разложить',
'inbox.keepHere': 'Оставить здесь',
'inbox.keepHere': 'Прикрепить в захваченном месте',
'inbox.localEmpty': 'Для этого дела неразобранных материалов нет',
'inbox.capturedIn': 'Захвачено в',
'inbox.suggestedTarget': 'Цель',
@ -61,7 +61,6 @@ export default {
'trash.emptyTrash': 'Очистить корзину',
'trash.restore': 'Восстановить',
'trash.deletedNodes': 'Удаленные элементы',
'trash.physicalEntries': 'Файлы в .verstak/trash',
'tab.overview': 'Обзор',
'tab.notes': 'Заметки',

View File

@ -143,7 +143,14 @@ async function runReadyScenario(cdp, url) {
await setClipboardText(cdp, 'https://example.test/from-clipboard')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await assertEval(cdp, `
(() => {
const row = [...document.querySelectorAll('.inbox-item')].find((el) => el.innerText.includes('example.test'));
if (!row) return false;
if (row.innerText.includes('Ссылка') || row.innerText.includes('URL') || row.innerText.includes('capture.kind.url')) return true;
throw new Error('URL row text: ' + row.innerText);
})()
`, 'inbox: clipboard URL kind visible')
await assertInboxItemButtonAbsent(cdp, 'example.test', 'Показать в проводнике', 'inbox: link has no explorer action')
await assertEval(cdp, `
(() => {
@ -203,7 +210,18 @@ async function runReadyScenario(cdp, url) {
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 assertEval(cdp, `!document.body.innerText.includes('.verstak/trash') && !document.body.innerText.includes('node-trash_Trash-Smoke-Folder')`, 'trash: physical implementation entries are hidden')
await click(cdp, '.trash-row.folder .trash-row-actions .inbox-icon-btn[title="Открыть"]')
await assertText(cdp, 'trash-child.txt', 'trash: opening deleted folder shows children immediately')
await assertText(cdp, 'Корзина / Trash Smoke Folder', 'trash: current folder breadcrumb visible')
await dispatchBodyKeydown(cdp, 'Backspace')
await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: Backspace returns to trash root')
await click(cdp, '.trash-row.folder .trash-row-actions .inbox-icon-btn[title="Открыть"]')
await dispatchMouseBack(cdp)
await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: mouse Back returns to trash root')
await clickText(cdp, '.nav-item', 'Журнал')
await clickText(cdp, '.nav-item', 'Корзина')
await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: sidebar return uses stable root state')
await screenshot(cdp, 'trash.png')
await clickText(cdp, '.tree-label', 'Smoke Project')
@ -223,6 +241,9 @@ async function runReadyScenario(cdp, url) {
await assertText(cdp, 'GUI smoke note', 'notes: created note appears')
await clickText(cdp, '.tab', 'Файлы')
await clickText(cdp, '.tab', 'Ссылки')
await dispatchBodyKeydown(cdp, 'Backspace')
await assertEval(cdp, `document.querySelector('.tab.active')?.innerText.includes('Файлы')`, 'navigation: Backspace returns to previous tab')
await waitForSelector(cdp, '.file-row')
await assertText(cdp, 'Assets', 'files: folder row visible')
await clickFolderOpenButton(cdp, 'Assets')
@ -241,6 +262,8 @@ async function runReadyScenario(cdp, url) {
await waitForSelector(cdp, '.modal input[type="text"]')
await dispatchKeydown(cdp, '.modal input[type="text"]', ' ')
await assertEval(cdp, `!!document.querySelector('.modal input[type="text"]')`, 'actions: space in title input keeps modal open')
await dispatchKeydown(cdp, '.modal input[type="text"]', 'Backspace')
await assertEval(cdp, `!!document.querySelector('.modal input[type="text"]')`, 'navigation: Backspace in input does not navigate')
await setInputValue(cdp, '.modal input[type="text"]', 'GUI smoke action with spaces')
await setInputValue(cdp, '.modal input[placeholder="https://example.com"]', 'https://example.test/action')
await clickText(cdp, '.modal-actions .btn', 'Создать')
@ -430,6 +453,33 @@ async function dispatchKeydown(cdp, selector, key) {
await sleep(100)
}
async function dispatchBodyKeydown(cdp, key) {
await cdp.send('Runtime.evaluate', {
expression: `
(() => {
document.body.focus();
const event = new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true, cancelable: true });
window.dispatchEvent(event);
})()
`,
returnByValue: true,
})
await sleep(150)
}
async function dispatchMouseBack(cdp) {
await cdp.send('Runtime.evaluate', {
expression: `
(() => {
const event = new MouseEvent('auxclick', { button: 3, bubbles: true, cancelable: true });
window.dispatchEvent(event);
})()
`,
returnByValue: true,
})
await sleep(150)
}
async function setClipboardText(cdp, value) {
await cdp.send('Runtime.evaluate', {
expression: `window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ = ${JSON.stringify(value)}`,
@ -537,7 +587,8 @@ async function evalValue(cdp, expression) {
userGesture: true,
})
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || 'Runtime.evaluate exception')
const detail = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime.evaluate exception'
throw new Error(detail)
}
return result.result?.value
}
@ -794,6 +845,11 @@ function wailsMockSource() {
links: {
'node-project': [],
},
trashNodes: [
{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', nodePath: 'Smoke / Trash Smoke Folder', deletedAt: now },
{ id: 'node-trash-child', parentId: 'node-trash', title: 'trash-child.txt', type: 'file', fsPath: 'Trash Smoke Folder/trash-child.txt', nodePath: 'Smoke / Trash Smoke Folder / trash-child.txt', deletedAt: now },
{ id: 'node-trash-nested', parentId: 'node-trash', title: 'Nested Trash Folder', type: 'folder', fsPath: 'Trash Smoke Folder/Nested Trash Folder', nodePath: 'Smoke / Trash Smoke Folder / Nested Trash Folder', deletedAt: now },
],
openedUrls: [],
};
@ -856,7 +912,7 @@ function wailsMockSource() {
function normalizeURL(value) {
const raw = String(value || '').trim();
if (!raw || /[\s]/.test(raw) || raw.includes('@')) return '';
if (!raw || /[\\s]/.test(raw) || raw.includes('@')) return '';
try {
const parsed = new URL(raw);
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : '';
@ -1049,9 +1105,22 @@ function wailsMockSource() {
OpenURL: async (url) => { state.openedUrls.push(url); return true },
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 }],
count: state.trashNodes.length,
nodes: state.trashNodes,
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 }],
}),
TrashCount: async () => state.trashNodes.length,
RestoreTrashNodesJSON: async (idsJSON) => {
const ids = JSON.parse(idsJSON || '[]');
state.trashNodes = state.trashNodes.filter((node) => !ids.includes(node.id));
return true;
},
PurgeTrashNodesJSON: async (idsJSON) => {
const ids = JSON.parse(idsJSON || '[]');
state.trashNodes = state.trashNodes.filter((node) => !ids.includes(node.id) && !ids.includes(node.parentId));
return true;
},
EmptyTrash: async () => { state.trashNodes = []; return true },
OpenTrashFolder: async () => true,
ListEnabledTemplates: async () => clone(templates),
AllTemplates: async () => clone(templates),