Compare commits
No commits in common. "cf770262e5a39da29ab487252a244232bf4cbe96" and "a37afd3b67722dde12c691094da7cc01703a0e02" have entirely different histories.
cf770262e5
...
a37afd3b67
67
AGENTS.md
67
AGENTS.md
|
|
@ -1,69 +1,3 @@
|
||||||
# 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
|
# Session summary
|
||||||
|
|
||||||
## Bugs fixed (this session)
|
## Bugs fixed (this session)
|
||||||
|
|
@ -148,4 +82,3 @@ go build -tags "webkit2_41 desktop production" -ldflags="-s -w" -o build/verstak
|
||||||
```bash
|
```bash
|
||||||
go build -ldflags="-s -w" -o build/verstak-server-linux-amd64 ./cmd/verstak-server/
|
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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-Co5H5J-5.js"></script>
|
<script type="module" crossorigin src="/assets/main-DLPp-1Ge.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -164,9 +164,6 @@
|
||||||
let inboxDropValid = false
|
let inboxDropValid = false
|
||||||
let captureDropActive = false
|
let captureDropActive = false
|
||||||
let captureDropLabel = ''
|
let captureDropLabel = ''
|
||||||
let captureDragDepth = 0
|
|
||||||
let lastCaptureDragOverAt = 0
|
|
||||||
let captureDragResetTimer = null
|
|
||||||
|
|
||||||
let showConfirm = false
|
let showConfirm = false
|
||||||
let confirmTitle = ''
|
let confirmTitle = ''
|
||||||
|
|
@ -387,16 +384,9 @@
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
window.addEventListener('paste', handleGlobalPaste)
|
window.addEventListener('paste', handleGlobalPaste)
|
||||||
window.addEventListener('dragenter', handleGlobalDragEnter)
|
|
||||||
window.addEventListener('dragover', handleGlobalDragOver)
|
window.addEventListener('dragover', handleGlobalDragOver)
|
||||||
window.addEventListener('dragleave', handleGlobalDragLeave)
|
window.addEventListener('dragleave', handleGlobalDragLeave)
|
||||||
window.addEventListener('dragend', resetCaptureDragState)
|
|
||||||
window.addEventListener('dragcancel', resetCaptureDragState)
|
|
||||||
window.addEventListener('drop', handleGlobalDrop)
|
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('auxclick', handleMouseNav)
|
||||||
window.addEventListener('mouseup', handleMouseNav)
|
window.addEventListener('mouseup', handleMouseNav)
|
||||||
|
|
||||||
|
|
@ -409,19 +399,11 @@
|
||||||
if (unlistenDrop) unlistenDrop()
|
if (unlistenDrop) unlistenDrop()
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
window.removeEventListener('paste', handleGlobalPaste)
|
window.removeEventListener('paste', handleGlobalPaste)
|
||||||
window.removeEventListener('dragenter', handleGlobalDragEnter)
|
|
||||||
window.removeEventListener('dragover', handleGlobalDragOver)
|
window.removeEventListener('dragover', handleGlobalDragOver)
|
||||||
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
||||||
window.removeEventListener('dragend', resetCaptureDragState)
|
|
||||||
window.removeEventListener('dragcancel', resetCaptureDragState)
|
|
||||||
window.removeEventListener('drop', handleGlobalDrop)
|
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('auxclick', handleMouseNav)
|
||||||
window.removeEventListener('mouseup', handleMouseNav)
|
window.removeEventListener('mouseup', handleMouseNav)
|
||||||
clearCaptureDragTimer()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== System view / Node selection =====
|
// ===== System view / Node selection =====
|
||||||
|
|
@ -792,11 +774,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.key === 'Escape' && captureDropActive) {
|
|
||||||
e.preventDefault()
|
|
||||||
resetCaptureDragState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isEditableTarget(e.target)) return
|
if (isEditableTarget(e.target)) return
|
||||||
|
|
||||||
if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowLeft')) {
|
if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowLeft')) {
|
||||||
|
|
@ -1757,12 +1734,8 @@
|
||||||
|
|
||||||
// ===== Drag-and-drop =====
|
// ===== Drag-and-drop =====
|
||||||
async function onFilesDropped(paths) {
|
async function onFilesDropped(paths) {
|
||||||
try {
|
if (!paths || paths.length === 0) return
|
||||||
if (!paths || paths.length === 0) return
|
await captureDroppedPaths(paths, 'drop')
|
||||||
await captureDroppedPaths(paths, 'drop')
|
|
||||||
} finally {
|
|
||||||
resetCaptureDragState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Helpers =====
|
// ===== Helpers =====
|
||||||
|
|
@ -2032,84 +2005,33 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function hasExternalCaptureData(dataTransfer) {
|
function hasExternalCaptureData(dataTransfer) {
|
||||||
if (dragIds.length > 0) return false
|
|
||||||
const types = Array.from(dataTransfer?.types || [])
|
const types = Array.from(dataTransfer?.types || [])
|
||||||
return types.includes('Files') ||
|
return types.includes('Files') ||
|
||||||
types.includes('text/uri-list') ||
|
types.includes('text/uri-list') ||
|
||||||
types.includes('text/x-moz-url') ||
|
types.includes('text/x-moz-url') ||
|
||||||
(types.includes('text/plain') && !types.includes('application/x-verstak-node'))
|
(types.includes('text/plain') && !types.includes('application/x-verstak-node'))
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function handleGlobalDragOver(e) {
|
||||||
if (!activateCaptureDrop(e.dataTransfer)) return
|
if (!hasExternalCaptureData(e.dataTransfer)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
captureDropLabel = captureContextLabel()
|
||||||
|
captureDropActive = true
|
||||||
}
|
}
|
||||||
function handleGlobalDragLeave(e) {
|
function handleGlobalDragLeave(e) {
|
||||||
if (captureDragDepth > 0) captureDragDepth -= 1
|
|
||||||
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
|
||||||
resetCaptureDragState()
|
captureDropActive = false
|
||||||
} else if (captureDragDepth <= 0) {
|
|
||||||
resetCaptureDragState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleVisibilityChange() {
|
|
||||||
if (document.hidden) {
|
|
||||||
resetCaptureDragState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function handleGlobalDrop(e) {
|
async function handleGlobalDrop(e) {
|
||||||
if (!hasExternalCaptureData(e.dataTransfer)) return
|
if (!hasExternalCaptureData(e.dataTransfer)) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
captureDropActive = false
|
||||||
try {
|
try {
|
||||||
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||||
if (captured) inboxCaptureStatus = t('inbox.captured')
|
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = String(err)
|
error = String(err)
|
||||||
} finally {
|
|
||||||
resetCaptureDragState()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleInboxDragOver(e) {
|
function handleInboxDragOver(e) {
|
||||||
|
|
@ -2123,14 +2045,9 @@
|
||||||
async function handleInboxDrop(e) {
|
async function handleInboxDrop(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
inboxDropValid = false
|
||||||
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
const captured = await captureTransferData(e.dataTransfer, 'drop')
|
||||||
if (captured) inboxCaptureStatus = t('inbox.captured')
|
if (captured) inboxCaptureStatus = t('inbox.captured')
|
||||||
} catch (err) {
|
|
||||||
error = String(err)
|
|
||||||
} finally {
|
|
||||||
resetCaptureDragState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function openAssignInbox(item) {
|
function openAssignInbox(item) {
|
||||||
assignInboxItem = item
|
assignInboxItem = item
|
||||||
|
|
|
||||||
|
|
@ -203,16 +203,6 @@ async function runReadyScenario(cdp, url) {
|
||||||
await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить')
|
await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить')
|
||||||
await clickText(cdp, '.overlay .btn', 'Удалить')
|
await clickText(cdp, '.overlay .btn', 'Удалить')
|
||||||
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox')
|
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 screenshot(cdp, 'inbox.png')
|
||||||
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
|
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')
|
||||||
|
|
@ -221,12 +211,12 @@ async function runReadyScenario(cdp, url) {
|
||||||
await clickText(cdp, '.nav-item', 'Корзина')
|
await clickText(cdp, '.nav-item', 'Корзина')
|
||||||
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible')
|
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 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-icon')
|
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-child.txt', 'trash: opening deleted folder shows children immediately')
|
||||||
await assertText(cdp, 'Корзина / Trash Smoke Folder', 'trash: current folder breadcrumb visible')
|
await assertText(cdp, 'Корзина / Trash Smoke Folder', 'trash: current folder breadcrumb visible')
|
||||||
await dispatchBodyKeydown(cdp, 'Backspace')
|
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 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-icon')
|
await click(cdp, '.trash-row.folder .trash-row-actions .inbox-icon-btn[title="Открыть"]')
|
||||||
await dispatchMouseBack(cdp)
|
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 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', 'Журнал')
|
||||||
|
|
@ -328,7 +318,6 @@ async function runReadyScenario(cdp, url) {
|
||||||
|
|
||||||
await clickText(cdp, '.nav-item', 'Журнал')
|
await clickText(cdp, '.nav-item', 'Журнал')
|
||||||
await waitForSelector(cdp, '.journal-screen')
|
await waitForSelector(cdp, '.journal-screen')
|
||||||
await clickText(cdp, '.journal-tab', 'Журнал работы')
|
|
||||||
await screenshot(cdp, 'journal.png')
|
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: filter section visible')
|
||||||
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('экспорт отчёта')`, 'journal: export section visible')
|
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('экспорт отчёта')`, 'journal: export section visible')
|
||||||
|
|
@ -539,32 +528,6 @@ async function emitDroppedFiles(cdp, paths) {
|
||||||
await sleep(300)
|
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) {
|
async function clickFolderOpenButton(cdp, name) {
|
||||||
const ok = await evalValue(cdp, `
|
const ok = await evalValue(cdp, `
|
||||||
(() => {
|
(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue