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; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-meJOE1Ze.js"></script> <script type="module" crossorigin src="/assets/main-OClTUyKu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-es_E5H-H.css"> <link rel="stylesheet" crossorigin href="/assets/main-BE1Aa3xO.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -92,6 +92,8 @@
let suggestions = [] let suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let inboxNodes = [] let inboxNodes = []
let inboxCaptureBusy = false
let inboxCaptureStatus = ''
let trashInfo = null let trashInfo = null
let showCreateNode = false let showCreateNode = false
let newNodeTitle = '' let newNodeTitle = ''
@ -565,9 +567,16 @@
// ===== Keyboard ===== // ===== Keyboard =====
function handleKeydown(e) { function handleKeydown(e) {
if (activeTab !== 'files') return
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') 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.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() } if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() } else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
@ -1382,6 +1391,39 @@
if (!source) return '' if (!source) return ''
return t('capture.source.' + source) 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) { 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
@ -2012,6 +2054,14 @@
<h2>{t('nav.inbox')}</h2> <h2>{t('nav.inbox')}</h2>
<p>{t('inbox.subtitle')}</p> <p>{t('inbox.subtitle')}</p>
</div> </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> </div>
{#if inboxNodes.length === 0} {#if inboxNodes.length === 0}
<div class="empty-state"> <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 { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.inbox-header h2 { margin: 0 0 6px; } .inbox-header h2 { margin: 0 0 6px; }
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; } .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-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 { 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; } .inbox-item:hover { border-color: #3a3a5c; background: #1e1e32; }

View File

@ -26,6 +26,10 @@ export default {
'nav.moveToRoot': 'Move to root', 'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Captured materials that still need to be assigned to cases', 'inbox.subtitle': 'Captured materials that still need to be assigned to cases',
'inbox.empty': 'No unprocessed items', '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.text': 'Text',
'capture.kind.url': 'Link', 'capture.kind.url': 'Link',
'capture.kind.file': 'File', 'capture.kind.file': 'File',

View File

@ -27,6 +27,10 @@ export default {
'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам', 'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам',
'inbox.empty': 'Неразобранных элементов нет', 'inbox.empty': 'Неразобранных элементов нет',
'inbox.pasteClipboard': 'Вставить из буфера',
'inbox.captured': 'Добавлено',
'inbox.clipboardEmpty': 'Буфер обмена пуст',
'inbox.clipboardUnavailable': 'Буфер обмена недоступен',
'capture.kind.text': 'Текст', 'capture.kind.text': 'Текст',
'capture.kind.url': 'Ссылка', '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 kind visible')
await assertText(cdp, 'Буфер обмена', 'inbox: capture source 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 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 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 assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
await clickText(cdp, '.nav-item', 'Корзина') await clickText(cdp, '.nav-item', 'Корзина')
@ -335,6 +339,13 @@ async function setInputValue(cdp, selector, value) {
await sleep(100) 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) { async function clickFolderOpenButton(cdp, name) {
const ok = await evalValue(cdp, ` const ok = await evalValue(cdp, `
(() => { (() => {
@ -559,6 +570,13 @@ function wailsMockSource() {
const clone = (value) => JSON.parse(JSON.stringify(value)); const clone = (value) => JSON.parse(JSON.stringify(value));
const mode = new URL(location.href).searchParams.get('smokeMode') || 'ready'; 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 = [ const templates = [
{ id: 'folder.default', title: 'template.folder.default', icon: 'folder', enabled: true }, { id: 'folder.default', title: 'template.folder.default', icon: 'folder', enabled: true },
{ id: 'project.default', title: 'template.project.default', icon: 'project', 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)), ListWorkspaceTree: async () => clone(state.nodes.filter((node) => node.captureInbox !== true)),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)), 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 || '' }))), 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({ ListTrash: async () => clone({
trashPath: '/tmp/verstak-smoke-vault/.verstak/trash', trashPath: '/tmp/verstak-smoke-vault/.verstak/trash',
nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }], nodes: [{ id: 'node-trash', title: 'Trash Smoke Folder', type: 'folder', fsPath: 'Trash Smoke Folder', deletedAt: now }],