fix: stabilize trash navigation and action icons
This commit is contained in:
parent
1fa009b1e2
commit
c8aaf36533
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-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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,62 +2892,65 @@
|
|||
<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>
|
||||
{/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>
|
||||
</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>
|
||||
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click={() => openTrashFolderNode(node)}>
|
||||
{@html actionIcon('open')}
|
||||
</button>
|
||||
{: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>
|
||||
<span class="inbox-icon-slot" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<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>
|
||||
<span class="trash-row-path">{entry.path}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Заметки',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue