Compare commits
2 Commits
a37afd3b67
...
cf770262e5
| Author | SHA1 | Date |
|---|---|---|
|
|
cf770262e5 | |
|
|
6033ccffa9 |
67
AGENTS.md
67
AGENTS.md
|
|
@ -1,3 +1,69 @@
|
|||
# Verstak project rules
|
||||
|
||||
## Project identity
|
||||
|
||||
Verstak is a local-first workbench for clients, projects, notes, files, tasks, activity and sync.
|
||||
It must remain practical, simple, and filesystem-aware.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: Go
|
||||
- Storage: SQLite
|
||||
- GUI: Wails v2
|
||||
- Frontend: Svelte 4
|
||||
- Build tooling: Vite 5
|
||||
- Do not migrate to Wails v3, Svelte 5, or Vite 8 unless explicitly asked.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Keep local-first behavior.
|
||||
- Do not turn the project into SaaS.
|
||||
- Do not replace SQLite with another database.
|
||||
- Do not introduce cloud storage assumptions.
|
||||
- Preserve recursive folder import semantics.
|
||||
- Preserve stable node IDs.
|
||||
- Do not duplicate nodes when moving items.
|
||||
- Do not create parallel state systems for the same entity.
|
||||
|
||||
## UI rules
|
||||
|
||||
- Fix GUI behavior at root cause.
|
||||
- Do not redesign the whole interface unless explicitly asked.
|
||||
- Preserve active tab state correctly.
|
||||
- Context menus must open near the cursor.
|
||||
- Drag-and-drop must show clear visual target feedback.
|
||||
- Moving nodes must never duplicate the same ID in two places.
|
||||
- Nested selection must not collapse the parent unexpectedly.
|
||||
|
||||
## Files
|
||||
|
||||
- File view is not a tree.
|
||||
- Sidebar shows logical hierarchy.
|
||||
- Vault filesystem layout must remain human-readable without the app.
|
||||
- Drag-and-drop folders must perform real recursive copy/move into the vault.
|
||||
- Do not fake folder support with external links.
|
||||
|
||||
## Sync
|
||||
|
||||
- Sync settings belong in Settings.
|
||||
- Main UI may keep only manual sync/status controls.
|
||||
- Existing URL + login/password device registration flow should be preserved unless explicitly changed.
|
||||
- Secrets must not be logged.
|
||||
|
||||
## Verification
|
||||
|
||||
For backend changes:
|
||||
- Run `go test ./...` if possible.
|
||||
|
||||
For frontend changes:
|
||||
- Run the relevant frontend build/check command if available.
|
||||
- If unsure, inspect package scripts first.
|
||||
|
||||
For GUI bugs:
|
||||
- Add targeted tests only where practical.
|
||||
- If manual GUI clicking is required and unavailable, state exact manual verification steps for the user.
|
||||
|
||||
|
||||
# Session summary
|
||||
|
||||
## Bugs fixed (this session)
|
||||
|
|
@ -82,3 +148,4 @@ go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak
|
|||
```bash
|
||||
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
|
||||
```
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -19,7 +19,7 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-DLPp-1Ge.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-Co5H5J-5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@
|
|||
let inboxDropValid = false
|
||||
let captureDropActive = false
|
||||
let captureDropLabel = ''
|
||||
let captureDragDepth = 0
|
||||
let lastCaptureDragOverAt = 0
|
||||
let captureDragResetTimer = null
|
||||
|
||||
let showConfirm = false
|
||||
let confirmTitle = ''
|
||||
|
|
@ -384,9 +387,16 @@
|
|||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('paste', handleGlobalPaste)
|
||||
window.addEventListener('dragenter', handleGlobalDragEnter)
|
||||
window.addEventListener('dragover', handleGlobalDragOver)
|
||||
window.addEventListener('dragleave', handleGlobalDragLeave)
|
||||
window.addEventListener('dragend', resetCaptureDragState)
|
||||
window.addEventListener('dragcancel', resetCaptureDragState)
|
||||
window.addEventListener('drop', handleGlobalDrop)
|
||||
window.addEventListener('mouseup', resetCaptureDragState)
|
||||
window.addEventListener('mouseleave', resetCaptureDragState)
|
||||
window.addEventListener('blur', resetCaptureDragState)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('auxclick', handleMouseNav)
|
||||
window.addEventListener('mouseup', handleMouseNav)
|
||||
|
||||
|
|
@ -399,11 +409,19 @@
|
|||
if (unlistenDrop) unlistenDrop()
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('paste', handleGlobalPaste)
|
||||
window.removeEventListener('dragenter', handleGlobalDragEnter)
|
||||
window.removeEventListener('dragover', handleGlobalDragOver)
|
||||
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
||||
window.removeEventListener('dragend', resetCaptureDragState)
|
||||
window.removeEventListener('dragcancel', resetCaptureDragState)
|
||||
window.removeEventListener('drop', handleGlobalDrop)
|
||||
window.removeEventListener('mouseup', resetCaptureDragState)
|
||||
window.removeEventListener('mouseleave', resetCaptureDragState)
|
||||
window.removeEventListener('blur', resetCaptureDragState)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('auxclick', handleMouseNav)
|
||||
window.removeEventListener('mouseup', handleMouseNav)
|
||||
clearCaptureDragTimer()
|
||||
})
|
||||
|
||||
// ===== System view / Node selection =====
|
||||
|
|
@ -774,6 +792,11 @@
|
|||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape' && captureDropActive) {
|
||||
e.preventDefault()
|
||||
resetCaptureDragState()
|
||||
return
|
||||
}
|
||||
if (isEditableTarget(e.target)) return
|
||||
|
||||
if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowLeft')) {
|
||||
|
|
@ -1734,8 +1757,12 @@
|
|||
|
||||
// ===== Drag-and-drop =====
|
||||
async function onFilesDropped(paths) {
|
||||
if (!paths || paths.length === 0) return
|
||||
await captureDroppedPaths(paths, 'drop')
|
||||
try {
|
||||
if (!paths || paths.length === 0) return
|
||||
await captureDroppedPaths(paths, 'drop')
|
||||
} finally {
|
||||
resetCaptureDragState()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
|
@ -2005,33 +2032,84 @@
|
|||
}
|
||||
}
|
||||
function hasExternalCaptureData(dataTransfer) {
|
||||
if (dragIds.length > 0) return false
|
||||
const types = Array.from(dataTransfer?.types || [])
|
||||
return types.includes('Files') ||
|
||||
types.includes('text/uri-list') ||
|
||||
types.includes('text/x-moz-url') ||
|
||||
(types.includes('text/plain') && !types.includes('application/x-verstak-node'))
|
||||
}
|
||||
function handleGlobalDragOver(e) {
|
||||
if (!hasExternalCaptureData(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
|
||||
function clearCaptureDragTimer() {
|
||||
if (captureDragResetTimer) {
|
||||
clearTimeout(captureDragResetTimer)
|
||||
captureDragResetTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetCaptureDragState() {
|
||||
captureDragDepth = 0
|
||||
captureDropActive = false
|
||||
captureDropLabel = ''
|
||||
lastCaptureDragOverAt = 0
|
||||
inboxDropValid = false
|
||||
clearCaptureDragTimer()
|
||||
}
|
||||
|
||||
function scheduleCaptureDragReset() {
|
||||
if (captureDragResetTimer) return
|
||||
captureDragResetTimer = setTimeout(() => {
|
||||
captureDragResetTimer = null
|
||||
if (captureDropActive && Date.now() - lastCaptureDragOverAt > 2000) {
|
||||
resetCaptureDragState()
|
||||
} else if (captureDropActive) {
|
||||
scheduleCaptureDragReset()
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function activateCaptureDrop(dataTransfer) {
|
||||
if (!hasExternalCaptureData(dataTransfer)) return false
|
||||
captureDropLabel = captureContextLabel()
|
||||
captureDropActive = true
|
||||
lastCaptureDragOverAt = Date.now()
|
||||
scheduleCaptureDragReset()
|
||||
return true
|
||||
}
|
||||
|
||||
function handleGlobalDragEnter(e) {
|
||||
if (!activateCaptureDrop(e.dataTransfer)) return
|
||||
captureDragDepth += 1
|
||||
}
|
||||
|
||||
function handleGlobalDragOver(e) {
|
||||
if (!activateCaptureDrop(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
function handleGlobalDragLeave(e) {
|
||||
if (captureDragDepth > 0) captureDragDepth -= 1
|
||||
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
||||
captureDropActive = false
|
||||
resetCaptureDragState()
|
||||
} else if (captureDragDepth <= 0) {
|
||||
resetCaptureDragState()
|
||||
}
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
resetCaptureDragState()
|
||||
}
|
||||
}
|
||||
async function handleGlobalDrop(e) {
|
||||
if (!hasExternalCaptureData(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
captureDropActive = false
|
||||
try {
|
||||
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||
} catch (err) {
|
||||
error = String(err)
|
||||
} finally {
|
||||
resetCaptureDragState()
|
||||
}
|
||||
}
|
||||
function handleInboxDragOver(e) {
|
||||
|
|
@ -2045,9 +2123,14 @@
|
|||
async function handleInboxDrop(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
inboxDropValid = false
|
||||
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||
try {
|
||||
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||
} catch (err) {
|
||||
error = String(err)
|
||||
} finally {
|
||||
resetCaptureDragState()
|
||||
}
|
||||
}
|
||||
function openAssignInbox(item) {
|
||||
assignInboxItem = item
|
||||
|
|
|
|||
|
|
@ -203,6 +203,16 @@ async function runReadyScenario(cdp, url) {
|
|||
await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить')
|
||||
await clickText(cdp, '.overlay .btn', 'Удалить')
|
||||
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox')
|
||||
await dispatchExternalDrag(cdp)
|
||||
await waitForSelector(cdp, '.capture-drop-overlay')
|
||||
await dispatchBodyKeydown(cdp, 'Escape')
|
||||
await waitForGone(cdp, '.capture-drop-overlay')
|
||||
await assertEval(cdp, `document.querySelector('.nav-item')?.click && !document.querySelector('.capture-drop-overlay')`, 'drag: Escape resets capture overlay')
|
||||
await dispatchExternalDrag(cdp)
|
||||
await waitForSelector(cdp, '.capture-drop-overlay')
|
||||
await dispatchWindowBlur(cdp)
|
||||
await waitForGone(cdp, '.capture-drop-overlay')
|
||||
await assertEval(cdp, `!document.querySelector('.capture-drop-overlay')`, 'drag: blur resets capture overlay')
|
||||
await screenshot(cdp, 'inbox.png')
|
||||
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
|
||||
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
|
||||
|
|
@ -211,12 +221,12 @@ async function runReadyScenario(cdp, url) {
|
|||
await clickText(cdp, '.nav-item', 'Корзина')
|
||||
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node 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 click(cdp, '.trash-row.folder .trash-row-icon')
|
||||
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 click(cdp, '.trash-row.folder .trash-row-icon')
|
||||
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', 'Журнал')
|
||||
|
|
@ -318,6 +328,7 @@ async function runReadyScenario(cdp, url) {
|
|||
|
||||
await clickText(cdp, '.nav-item', 'Журнал')
|
||||
await waitForSelector(cdp, '.journal-screen')
|
||||
await clickText(cdp, '.journal-tab', 'Журнал работы')
|
||||
await screenshot(cdp, 'journal.png')
|
||||
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('фильтры')`, 'journal: filter section visible')
|
||||
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('экспорт отчёта')`, 'journal: export section visible')
|
||||
|
|
@ -528,6 +539,32 @@ async function emitDroppedFiles(cdp, paths) {
|
|||
await sleep(300)
|
||||
}
|
||||
|
||||
async function dispatchExternalDrag(cdp) {
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
(() => {
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File(['smoke'], 'drag-smoke.txt', { type: 'text/plain' }));
|
||||
const enter = new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: data });
|
||||
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: data });
|
||||
window.dispatchEvent(enter);
|
||||
window.dispatchEvent(over);
|
||||
})()
|
||||
`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
async function dispatchWindowBlur(cdp) {
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `window.dispatchEvent(new Event('blur'))`,
|
||||
returnByValue: true,
|
||||
})
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
async function clickFolderOpenButton(cdp, name) {
|
||||
const ok = await evalValue(cdp, `
|
||||
(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue