feat: add local inbox and links tabs

This commit is contained in:
mirivlad 2026-06-05 07:44:38 +08:00
parent c1dfc456ec
commit f112e9a2d0
4 changed files with 238 additions and 5 deletions

View File

@ -92,8 +92,15 @@
let suggestions = [] let suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let inboxNodes = [] let inboxNodes = []
let localInboxNodes = []
let inboxCaptureBusy = false let inboxCaptureBusy = false
let inboxCaptureStatus = '' let inboxCaptureStatus = ''
let links = []
let editingLink = null
let linkTitle = ''
let linkURL = ''
let linkNote = ''
let linkStatus = ''
let trashInfo = null let trashInfo = null
let showCreateNode = false let showCreateNode = false
let newNodeTitle = '' let newNodeTitle = ''
@ -170,6 +177,8 @@
{ id: 'overview', label: t('tab.overview') }, { id: 'overview', label: t('tab.overview') },
{ id: 'notes', label: t('tab.notes') }, { id: 'notes', label: t('tab.notes') },
{ id: 'files', label: t('tab.files') }, { id: 'files', label: t('tab.files') },
{ id: 'inbox', label: t('tab.inbox') },
{ id: 'links', label: t('tab.links') },
{ id: 'actions', label: t('tab.actions') }, { id: 'actions', label: t('tab.actions') },
{ id: 'worklog', label: t('tab.worklog') }, { id: 'worklog', label: t('tab.worklog') },
{ id: 'activity', label: t('tab.activity') }, { id: 'activity', label: t('tab.activity') },
@ -248,6 +257,8 @@
files = [] files = []
actions = [] actions = []
worklog = [] worklog = []
localInboxNodes = []
links = []
suggestions = [] suggestions = []
inboxNodes = [] inboxNodes = []
trashInfo = null trashInfo = null
@ -312,6 +323,8 @@
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {} try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {} try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
try { worklog = initWorklogEntries(await wailsCall('ListWorklog', nodeID)) } catch(e) {} try { worklog = initWorklogEntries(await wailsCall('ListWorklog', nodeID)) } catch(e) {}
try { localInboxNodes = await wailsCall('ListInboxNodesForTarget', nodeID) || [] } catch(e) { localInboxNodes = [] }
try { links = await wailsCall('ListLinks', nodeID) || [] } catch(e) { links = [] }
try { try {
suggestions = await wailsCall('GetSuggestions') || [] suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
@ -1395,7 +1408,7 @@
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str } try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
} }
function nodeKindLabel(kind) { function nodeKindLabel(kind) {
const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'folder': t('kind.folder'), 'note': t('kind.note'), 'file': t('kind.file'), 'archive': t('kind.archive'), 'case': t('kind.case') } const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'folder': t('kind.folder'), 'note': t('kind.note'), 'file': t('kind.file'), 'archive': t('kind.archive'), 'case': t('kind.case'), 'link': t('kind.link') }
return labels[kind] || kind || t('kind.case') return labels[kind] || kind || t('kind.case')
} }
function captureKindLabel(kind) { function captureKindLabel(kind) {
@ -1409,6 +1422,9 @@
function addInboxCapture(item) { function addInboxCapture(item) {
if (!item || !item.id) return if (!item || !item.id) return
inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)] inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)]
if (selectedNode && (item.captureContextNodeId === selectedNode.id || item.suggestedTargetNodeId === selectedNode.id)) {
localInboxNodes = [item, ...localInboxNodes.filter(existing => existing.id !== item.id)]
}
} }
function looksLikeURL(value) { function looksLikeURL(value) {
try { try {
@ -1689,14 +1705,31 @@
async function submitAssignInbox() { async function submitAssignInbox() {
if (!assignInboxItem || !inboxAssignTarget) return if (!assignInboxItem || !inboxAssignTarget) return
try { try {
await wailsCall('AssignInboxNode', assignInboxItem.id, inboxAssignTarget.id) await resolveInboxToTarget(assignInboxItem, inboxAssignTarget.id)
inboxNodes = inboxNodes.filter(item => item.id !== assignInboxItem.id)
await reloadTreePreservingExpanded()
closeAssignInbox() closeAssignInbox()
} catch (e) { } catch (e) {
error = String(e) error = String(e)
} }
} }
async function resolveInboxToTarget(item, targetNodeId) {
if (!item || !targetNodeId) return
await wailsCall('ResolveInboxNode', item.id, targetNodeId)
inboxNodes = inboxNodes.filter(existing => existing.id !== item.id)
localInboxNodes = localInboxNodes.filter(existing => existing.id !== item.id)
await reloadTreePreservingExpanded()
if (selectedNode) {
await loadTabData(selectedNode.id)
}
}
async function resolveInboxHere(item) {
const targetId = item?.suggestedTargetNodeId || selectedNode?.id || ''
if (!targetId) return
try {
await resolveInboxToTarget(item, targetId)
} catch (e) {
error = String(e)
}
}
function confirmDeleteInbox(item) { function confirmDeleteInbox(item) {
openConfirm({ openConfirm({
title: t('inbox.deleteTitle'), title: t('inbox.deleteTitle'),
@ -1707,12 +1740,68 @@
try { try {
await wailsCall('DeleteInboxNode', item.id) await wailsCall('DeleteInboxNode', item.id)
inboxNodes = inboxNodes.filter(existing => existing.id !== item.id) inboxNodes = inboxNodes.filter(existing => existing.id !== item.id)
localInboxNodes = localInboxNodes.filter(existing => existing.id !== item.id)
} catch (e) { } catch (e) {
error = String(e) error = String(e)
} }
} }
}) })
} }
function openEditLink(link) {
editingLink = link
linkTitle = link.title || ''
linkURL = link.url || ''
linkNote = link.note || ''
linkStatus = ''
}
function closeEditLink() {
editingLink = null
linkTitle = ''
linkURL = ''
linkNote = ''
linkStatus = ''
}
async function submitEditLink() {
if (!editingLink || !linkURL.trim()) return
try {
const updated = await wailsCall('UpdateLink', editingLink.id, linkTitle.trim(), linkURL.trim(), linkNote)
links = links.map(link => link.id === updated.id ? updated : link)
closeEditLink()
} catch (e) {
linkStatus = String(e)
}
}
async function deleteLink(link) {
openConfirm({
title: t('links.deleteTitle'),
message: t('links.deleteConfirm', { title: link.title }),
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteLink', link.id)
links = links.filter(item => item.id !== link.id)
} catch (e) {
error = String(e)
}
}
})
}
async function openLink(link) {
try {
await wailsCall('OpenLink', link.id)
} catch (e) {
error = String(e)
}
}
async function copyLinkURL(link) {
try {
await navigator.clipboard?.writeText?.(link.url)
linkStatus = t('links.copied')
} catch (e) {
linkStatus = t('links.copyUnavailable')
}
}
function pluralize(n, one, few, many) { function pluralize(n, one, few, many) {
n = Math.abs(n) % 100 n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many if (n >= 5 && n <= 20) return many
@ -2188,6 +2277,63 @@
/> />
{/if} {/if}
{:else if activeTab === 'inbox'}
<div class="inbox-tab">
{#if localInboxNodes.length === 0}
<div class="empty-state"><p>{t('inbox.localEmpty')}</p></div>
{:else}
<div class="inbox-list">
{#each localInboxNodes as item}
<div class="inbox-item" role="button" tabindex="0" on:click={() => openNodeById(item.id)} on:keydown={(e) => e.key === 'Enter' && openNodeById(item.id)}>
<div class="inbox-item-main">
<span class="inbox-item-title">{item.title}</span>
<span class="inbox-item-meta">
{#if item.captureKind}{captureKindLabel(item.captureKind)} · {/if}
{#if item.captureSource}{captureSourceLabel(item.captureSource)} · {/if}
{#if item.captureContextLabel}{t('inbox.capturedIn')}: {item.captureContextLabel} · {/if}
{formatDate(item.capturedAt || item.createdAt)}
</span>
</div>
<div class="inbox-item-actions">
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => resolveInboxHere(item)}>{t('inbox.keepHere')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => confirmDeleteInbox(item)}>{t('common.delete')}</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'links'}
<div class="links-tab">
{#if links.length === 0}
<div class="empty-state"><p>{t('links.empty')}</p></div>
{:else}
<div class="links-list">
{#each links as link}
<div class="link-card">
<div class="link-main">
<span class="link-title">{link.title}</span>
<span class="link-url" title={link.url}>{link.hostname || link.url}</span>
<span class="link-date">{formatDate(link.createdAt)}</span>
{#if link.note}<span class="link-note">{link.note}</span>{/if}
</div>
<div class="link-actions">
<button class="btn btn-sm btn-primary" on:click={() => openLink(link)}>{t('common.open')}</button>
<button class="btn btn-sm" on:click={() => copyLinkURL(link)}>{t('links.copyUrl')}</button>
<button class="btn btn-sm" on:click={() => openEditLink(link)}>{t('common.rename')}</button>
<button class="btn btn-sm btn-danger" on:click={() => deleteLink(link)}>{t('common.delete')}</button>
</div>
</div>
{/each}
</div>
{/if}
{#if linkStatus}
<div class="link-status">{linkStatus}</div>
{/if}
</div>
{:else if activeTab === 'actions'} {:else if activeTab === 'actions'}
<div class="actions-tab"> <div class="actions-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
@ -2380,6 +2526,9 @@
</span> </span>
</div> </div>
<div class="inbox-item-actions"> <div class="inbox-item-actions">
{#if item.suggestedTargetNodeId}
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => resolveInboxHere(item)}>{t('inbox.keepHere')}</button>
{/if}
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button> <button class="btn btn-sm btn-primary" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button> <button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button> <button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
@ -3032,6 +3181,36 @@
</div> </div>
{/if} {/if}
{#if editingLink}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeEditLink} on:keydown={onKeyActivate(closeEditLink)}>
<div class="modal">
<h3>{t('links.editTitle')}</h3>
<div class="form-group">
<label><span class="label-text">{t('common.name')}</span>
<input type="text" bind:value={linkTitle} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">URL</span>
<input type="url" bind:value={linkURL} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('links.note')}</span>
<textarea bind:value={linkNote}></textarea>
</label>
</div>
{#if linkStatus}
<div class="rename-error">{linkStatus}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitEditLink} disabled={!linkURL.trim()}>{t('common.save')}</button>
<button class="btn" on:click={closeEditLink}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
{#if showConfirm} {#if showConfirm}
<ConfirmModal <ConfirmModal
title={confirmTitle} title={confirmTitle}
@ -3198,6 +3377,20 @@
.inbox-item-title { color: #e4e4ef; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .inbox-item-title { color: #e4e4ef; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inbox-item-meta { color: #8888a0; font-size: 12px; } .inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; } .inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; }
.inbox-tab { padding: 24px; }
/* Links tab */
.links-tab { padding: 24px; }
.links-list { display: flex; flex-direction: column; gap: 8px; }
.link-card { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; }
.link-card:hover { border-color: #3a3a5c; background: #1e1e32; }
.link-main { flex: 1; min-width: 0; display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 220px) auto; gap: 8px 12px; align-items: baseline; }
.link-title { color: #e4e4ef; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-url { color: #a5b4fc; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-date { color: #8888a0; font-size: 12px; white-space: nowrap; }
.link-note { grid-column: 1 / -1; color: #8888a0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-actions { display: flex; gap: 8px; flex-shrink: 0; }
.link-status { margin-top: 12px; color: #8ee6b1; font-size: 12px; }
/* Trash screen */ /* Trash screen */
.trash-screen { padding: 24px; overflow-y: auto; flex: 1; } .trash-screen { padding: 24px; overflow-y: auto; flex: 1; }

View File

@ -31,6 +31,9 @@ export default {
'inbox.clipboardEmpty': 'Clipboard is empty', 'inbox.clipboardEmpty': 'Clipboard is empty',
'inbox.clipboardUnavailable': 'Clipboard is unavailable', 'inbox.clipboardUnavailable': 'Clipboard is unavailable',
'inbox.assign': 'Assign', 'inbox.assign': 'Assign',
'inbox.keepHere': 'Keep here',
'inbox.localEmpty': 'No unprocessed materials for this case',
'inbox.capturedIn': 'Captured in',
'inbox.assignTitle': 'Assign material', 'inbox.assignTitle': 'Assign material',
'inbox.assignTarget': 'Case', 'inbox.assignTarget': 'Case',
'inbox.assignSearchPlaceholder': 'Find case', 'inbox.assignSearchPlaceholder': 'Find case',
@ -56,6 +59,8 @@ export default {
'tab.overview': 'Overview', 'tab.overview': 'Overview',
'tab.notes': 'Notes', 'tab.notes': 'Notes',
'tab.files': 'Files', 'tab.files': 'Files',
'tab.inbox': 'Inbox',
'tab.links': 'Links',
'tab.actions': 'Actions', 'tab.actions': 'Actions',
'tab.worklog': 'Work Log', 'tab.worklog': 'Work Log',
'tab.activity': 'Activity', 'tab.activity': 'Activity',
@ -118,6 +123,15 @@ export default {
'kind.file': 'File', 'kind.file': 'File',
'kind.archive': 'Archive', 'kind.archive': 'Archive',
'kind.case': 'Case', 'kind.case': 'Case',
'kind.link': 'Link',
'links.empty': 'No links yet',
'links.editTitle': 'Edit link',
'links.note': 'Note',
'links.copyUrl': 'Copy URL',
'links.copied': 'URL copied',
'links.copyUnavailable': 'Could not copy URL',
'links.deleteTitle': 'Delete link',
'links.deleteConfirm': 'Delete link "{title}"?',
'action.openUrl': 'Open URL', 'action.openUrl': 'Open URL',
'action.openFile': 'Open file', 'action.openFile': 'Open file',
'action.openFolder': 'Open folder', 'action.openFolder': 'Open folder',

View File

@ -32,6 +32,9 @@ export default {
'inbox.clipboardEmpty': 'Буфер обмена пуст', 'inbox.clipboardEmpty': 'Буфер обмена пуст',
'inbox.clipboardUnavailable': 'Буфер обмена недоступен', 'inbox.clipboardUnavailable': 'Буфер обмена недоступен',
'inbox.assign': 'Разложить', 'inbox.assign': 'Разложить',
'inbox.keepHere': 'Оставить здесь',
'inbox.localEmpty': 'Для этого дела неразобранных материалов нет',
'inbox.capturedIn': 'Захвачено в',
'inbox.assignTitle': 'Разложить материал', 'inbox.assignTitle': 'Разложить материал',
'inbox.assignTarget': 'Дело', 'inbox.assignTarget': 'Дело',
'inbox.assignSearchPlaceholder': 'Найти дело', 'inbox.assignSearchPlaceholder': 'Найти дело',
@ -60,6 +63,8 @@ export default {
'tab.overview': 'Обзор', 'tab.overview': 'Обзор',
'tab.notes': 'Заметки', 'tab.notes': 'Заметки',
'tab.files': 'Файлы', 'tab.files': 'Файлы',
'tab.inbox': 'Неразобранное',
'tab.links': 'Ссылки',
'tab.actions': 'Действия', 'tab.actions': 'Действия',
'tab.worklog': 'Журнал', 'tab.worklog': 'Журнал',
'tab.activity': 'Активность', 'tab.activity': 'Активность',
@ -126,6 +131,16 @@ export default {
'kind.file': 'Файл', 'kind.file': 'Файл',
'kind.archive': 'Архив', 'kind.archive': 'Архив',
'kind.case': 'Дело', 'kind.case': 'Дело',
'kind.link': 'Ссылка',
'links.empty': 'Ссылок пока нет',
'links.editTitle': 'Редактировать ссылку',
'links.note': 'Заметка',
'links.copyUrl': 'Копировать URL',
'links.copied': 'URL скопирован',
'links.copyUnavailable': 'Не удалось скопировать URL',
'links.deleteTitle': 'Удалить ссылку',
'links.deleteConfirm': 'Удалить ссылку «{title}»?',
'action.openUrl': 'Открыть URL', 'action.openUrl': 'Открыть URL',
'action.openFile': 'Открыть файл', 'action.openFile': 'Открыть файл',

View File

@ -144,6 +144,14 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера') await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured') await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible') await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await clickInboxItemButton(cdp, 'example.test', 'Разложить')
await waitForSelector(cdp, '.modal input[type="text"]')
await setInputValue(cdp, '.modal input[type="text"]', 'Smoke')
await waitForSelector(cdp, '.assign-search-result')
await clickText(cdp, '.assign-search-result', 'Smoke Project')
await clickText(cdp, '.modal-actions .btn', 'Разложить')
await waitForGone(cdp, '.modal-overlay')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder']) await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured') await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible') await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
@ -174,7 +182,7 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.tree-label', 'Smoke Project') await clickText(cdp, '.tree-label', 'Smoke Project')
await waitForSelector(cdp, '.tabs') await waitForSelector(cdp, '.tabs')
await assertText(cdp, 'Smoke Project', 'node: selected project visible') await assertText(cdp, 'Smoke Project', 'node: selected project visible')
await assertEval(cdp, `document.querySelectorAll('.tab').length === 6`, 'node: all primary tabs rendered') await assertEval(cdp, `document.querySelectorAll('.tab').length === 8`, 'node: all primary tabs rendered')
await screenshot(cdp, 'node-overview.png') await screenshot(cdp, 'node-overview.png')
await clickText(cdp, '.tab', 'Заметки') await clickText(cdp, '.tab', 'Заметки')
@ -195,6 +203,9 @@ async function runReadyScenario(cdp, url) {
await click(cdp, '.back-btn') await click(cdp, '.back-btn')
await assertText(cdp, 'brief.md', 'files: back button returns to parent folder') await assertText(cdp, 'brief.md', 'files: back button returns to parent folder')
await clickText(cdp, '.tab', 'Ссылки')
await assertText(cdp, 'example.test', 'links: resolved link visible')
await clickText(cdp, '.tab', 'Действия') await clickText(cdp, '.tab', 'Действия')
await assertText(cdp, 'Deploy smoke', 'actions: action card visible') await assertText(cdp, 'Deploy smoke', 'actions: action card visible')