feat: capture clipboard links in inbox gui

This commit is contained in:
mirivlad 2026-06-05 01:55:38 +08:00
parent 44d0be2649
commit 326f6f283d
9 changed files with 96 additions and 8 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

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-meJOE1Ze.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-es_E5H-H.css">
<script type="module" crossorigin src="/assets/main-OClTUyKu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BE1Aa3xO.css">
</head>
<body>
<div id="app"></div>

View File

@ -92,6 +92,8 @@
let suggestions = []
let suggestionCount = 0
let inboxNodes = []
let inboxCaptureBusy = false
let inboxCaptureStatus = ''
let trashInfo = null
let showCreateNode = false
let newNodeTitle = ''
@ -565,9 +567,16 @@
// ===== Keyboard =====
function handleKeydown(e) {
if (activeTab !== 'files') return
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (selectedSection === 'inbox' && (e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
e.preventDefault()
captureClipboard()
return
}
if (activeTab !== 'files') return
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
@ -1382,6 +1391,39 @@
if (!source) return ''
return t('capture.source.' + source)
}
function looksLikeURL(value) {
try {
const parsed = new URL(value)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch (e) {
return false
}
}
async function captureClipboard() {
if (inboxCaptureBusy) return
inboxCaptureStatus = ''
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
error = t('inbox.clipboardUnavailable')
return
}
inboxCaptureBusy = true
try {
const text = (await navigator.clipboard.readText()).trim()
if (!text) {
inboxCaptureStatus = t('inbox.clipboardEmpty')
return
}
const item = looksLikeURL(text)
? await wailsCall('CaptureURL', text, '')
: await wailsCall('CaptureText', text)
inboxNodes = [item, ...inboxNodes.filter(existing => existing.id !== item.id)]
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
error = String(e)
} finally {
inboxCaptureBusy = false
}
}
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
@ -2012,6 +2054,14 @@
<h2>{t('nav.inbox')}</h2>
<p>{t('inbox.subtitle')}</p>
</div>
<div class="inbox-header-actions">
<button class="btn btn-primary" on:click={captureClipboard} disabled={inboxCaptureBusy}>
{inboxCaptureBusy ? t('common.loading') : t('inbox.pasteClipboard')}
</button>
{#if inboxCaptureStatus}
<span class="inbox-capture-status">{inboxCaptureStatus}</span>
{/if}
</div>
</div>
{#if inboxNodes.length === 0}
<div class="empty-state">
@ -2801,6 +2851,8 @@
.inbox-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.inbox-header h2 { margin: 0 0 6px; }
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
.inbox-header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.inbox-capture-status { color: #8ee6b1; font-size: 12px; }
.inbox-list { display: flex; flex-direction: column; gap: 8px; }
.inbox-item { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
.inbox-item:hover { border-color: #3a3a5c; background: #1e1e32; }

View File

@ -26,6 +26,10 @@ export default {
'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Captured materials that still need to be assigned to cases',
'inbox.empty': 'No unprocessed items',
'inbox.pasteClipboard': 'Paste from clipboard',
'inbox.captured': 'Added',
'inbox.clipboardEmpty': 'Clipboard is empty',
'inbox.clipboardUnavailable': 'Clipboard is unavailable',
'capture.kind.text': 'Text',
'capture.kind.url': 'Link',
'capture.kind.file': 'File',

View File

@ -27,6 +27,10 @@ export default {
'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам',
'inbox.empty': 'Неразобранных элементов нет',
'inbox.pasteClipboard': 'Вставить из буфера',
'inbox.captured': 'Добавлено',
'inbox.clipboardEmpty': 'Буфер обмена пуст',
'inbox.clipboardUnavailable': 'Буфер обмена недоступен',
'capture.kind.text': 'Текст',
'capture.kind.url': 'Ссылка',

View File

@ -140,8 +140,12 @@ async function runReadyScenario(cdp, url) {
await assertText(cdp, 'Текст', 'inbox: capture kind visible')
await assertText(cdp, 'Буфер обмена', 'inbox: capture source visible')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('Manual Root Item')`, 'inbox: manual root is hidden')
await setClipboardText(cdp, 'https://example.test/from-clipboard')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'https://example.test/from-clipboard', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await screenshot(cdp, 'inbox.png')
await clickText(cdp, '.inbox-item-actions .btn', 'Открыть')
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
await clickText(cdp, '.nav-item', 'Корзина')
@ -335,6 +339,13 @@ async function setInputValue(cdp, selector, value) {
await sleep(100)
}
async function setClipboardText(cdp, value) {
await cdp.send('Runtime.evaluate', {
expression: `window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ = ${JSON.stringify(value)}`,
returnByValue: true,
})
}
async function clickFolderOpenButton(cdp, name) {
const ok = await evalValue(cdp, `
(() => {
@ -559,6 +570,13 @@ function wailsMockSource() {
const clone = (value) => JSON.parse(JSON.stringify(value));
const mode = new URL(location.href).searchParams.get('smokeMode') || 'ready';
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '',
},
});
const templates = [
{ id: 'folder.default', title: 'template.folder.default', icon: 'folder', enabled: true },
{ id: 'project.default', title: 'template.project.default', icon: 'project', enabled: true },
@ -691,6 +709,16 @@ function wailsMockSource() {
ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && node.captureInbox === true).map((node) => ({ ...node, captureKind: node.captureKind || '', captureSource: node.captureSource || '' }))),
CaptureText: async (text) => {
const node = { id: 'node-capture-text-' + Date.now(), title: String(text || '').trim().split('\\n').find(Boolean) || 'Captured text', type: 'note', section: '', captureInbox: true, captureKind: 'text', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
},
CaptureURL: async (url, title) => {
const node = { id: 'node-capture-url-' + Date.now(), title: title || url, type: 'note', section: '', captureInbox: true, captureKind: 'url', captureSource: 'clipboard', createdAt: now, has_children: false, children: [] };
state.nodes.push(node);
return clone({ ...node, captureKind: node.captureKind, captureSource: node.captureSource });
},
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 }],