chore(frontend): add diagnostics before app split

- main.js: global error handlers (error + unhandledrejection), try/catch around App mount with inline fallback UI
- bindings_logging.go: FrontendLog() and LogStartupStep() backend bindings for persistent application logging
- App.js: FrontendLog() and LogStartupStep() Wails JS bindings
- docs/frontend-architecture.md: component architecture, state ownership rules, communication patterns, logging guide, troubleshooting

All existing code untouched. This is a pure additive step that enables
blank-window debugging before any App.svelte extraction begins.
This commit is contained in:
mirivlad 2026-06-16 02:22:44 +08:00
parent bfe57ac0ac
commit df21340402
6 changed files with 335 additions and 5 deletions

View File

@ -0,0 +1,89 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"verstak/internal/core/config"
)
// appLogPath returns the path to the application log file.
// Uses ~/.local/state/verstak/logs/verstak.log on Linux,
// or falls back to ~/.config/verstak/logs/verstak.log.
func appLogPath() string {
// Prefer XDG_STATE_HOME, then fallback to config dir
stateDir := os.Getenv("XDG_STATE_HOME")
if stateDir == "" {
home, err := os.UserHomeDir()
if err == nil {
stateDir = filepath.Join(home, ".local", "state")
}
}
if stateDir != "" {
dir := filepath.Join(stateDir, "verstak", "logs")
if err := os.MkdirAll(dir, 0o755); err == nil {
return filepath.Join(dir, "verstak.log")
}
}
// Fallback to config dir
cfgDir, err := config.EnsureConfigDir()
if err != nil {
return ""
}
dir := filepath.Join(cfgDir, "logs")
if err := os.MkdirAll(dir, 0o755); err != nil {
return ""
}
return filepath.Join(dir, "verstak.log")
}
// appLog writes a timestamped line to the application log file.
func appLog(level, msg string) {
logPath := appLogPath()
if logPath == "" {
log.Printf("[%s] %s", level, msg)
return
}
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), level, msg)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
log.Printf("[%s] %s", level, msg)
return
}
defer f.Close()
f.WriteString(line)
}
// FrontendLog receives log messages from the frontend runtime.
// Called from JS via Wails binding. Works even if vault is not open.
// level: "info", "warn", "error"
// message: human-readable message
// stack: JS stack trace (optional, only for errors)
func (a *App) FrontendLog(level, message, stack string) {
msg := "[frontend] " + message
if stack != "" {
msg += "\n stack: " + stack
}
// Always log to Go's standard logger (visible in dev mode console)
log.Printf("[frontend][%s] %s", level, message)
// Persist to log file
appLog("frontend-"+level, msg)
}
// LogStartupStep logs a startup diagnostic step.
// Called from Go side to trace initialization progress.
func (a *App) LogStartupStep(step string, success bool, detail string) {
status := "ok"
if !success {
status = "fail"
}
msg := fmt.Sprintf("[startup] %s: %s", step, status)
if detail != "" {
msg += " — " + detail
}
log.Print(msg)
appLog("startup", msg)
}

View File

@ -19,7 +19,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script> <script type="module" crossorigin src="/assets/main-Da3BSkUM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css"> <link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head> </head>
<body> <body>

View File

@ -0,0 +1,178 @@
# Frontend Architecture — Verstak GUI
## Tech Stack
- **Framework**: Svelte 4 (runes-free, compiler-only)
- **Build tool**: Vite 5
- **GUI shell**: Wails v2 (Go backend + WebKit frontend)
- **Language**: Plain JavaScript (no TypeScript in Svelte files — `lang="ts"` is NOT used)
## Source Structure
```
frontend/src/
main.js # Entry: mounts App.svelte, global error handlers
App.svelte # Root component: sidebar, header, modals, tab router
FileTreeRow.svelte # Sidebar tree node row
TreeNode.svelte # Recursive tree node
lib/
AppHeader.svelte # Top bar with title, search, actions
BrowserEvents.svelte # Browser extension events panel
CalendarPluginPage.svelte # Plugin page host
ConfirmModal.svelte # Reusable confirm dialog
FileBreadcrumbs.svelte # File tab breadcrumb navigation
FileIcon.svelte # Icon resolver for file types
FilePreviewModal.svelte # File content preview modal
FirstRun.svelte # First-run wizard
GlobalSearch.svelte # Global search bar + results
SettingsWindow.svelte # Settings modal container
Settings*.svelte # Settings sub-sections (General, Sync, etc.)
SyncStatus.svelte # Sync status badge
TemplateIcon.svelte # Template type icon
TodayScreen.svelte # Today dashboard
VaultRecovery.svelte # Vault recovery wizard
actionIcons.js # SVG icon strings
fileUtils.js # File type helpers (canPreview, isMarkdown, etc.)
i18n/ # Internationalization (en, ru)
markdown/ # Markdown rendering + internal links
util/ # Keyboard layout helper
components/
notes/
MarkdownEditor.svelte # Markdown textarea with toolbar
MarkdownPreview.svelte # Rendered markdown preview
NoteEditorPanel.svelte # Editor + preview layout, public API: insertText()
InternalLinkPicker.svelte # Object picker for verstak:// links
ObjectPickerModal.svelte # Legacy object picker modal
```
## Role of App.svelte
`App.svelte` is the **root component**. It owns:
1. **Global UI state**: sidebar (system views, workspace tree), active tab, selected node/section
2. **Lifecycle**: startup checks (GetStartupStatus, VerstakVersion, ListWorkspaceTree), event listeners
3. **Top-level modals**: confirm, rename, import, worklog, inbox assign, link edit, settings, file preview
4. **Cross-cutting concerns**: navigation history (goBack, rememberNavigation), keyboard shortcuts, drag-and-drop orchestration, capture/inbox flow
App.svelte is **NOT** responsible for:
- Individual tab content (Overview, Notes, Files) — these are inlined but should be extracted
- Note editor internals — `NoteEditorPanel.svelte` + `MarkdownEditor.svelte` handle this
- Settings sections — each has its own `Settings*.svelte`
## State Ownership Rules
### App.svelte MUST own:
- `selectedSection`, `selectedNode`, `activeTab`
- `systemViews`, `workspaceTree`, `enabledTemplates`
- `noteEditor` (the note being edited), `noteViewMode`
- `showFirstRun`, `showRecovery`, `showSettings`, `loading`
- `startupStatus`, `startupChecked`
- Navigation state: `navHistory`, `restoringHistory`
- Trash browser state: `trashInfo`, `trashCount`, `trashSelectedIds`, `trashFolderId`, `trashFolderStack`
- Journal state: `journalRows`, `journalSummary`, filters
- Worklog modal state: `showWorklogModal`, `wlModal*`
- Inbox: `inboxNodes`, `localInboxNodes`, capture state
- Links: `links`
- Sync: `syncStatus`
- `error` (global error banner)
### App.svelte must NOT directly own (after extraction):
- Files tab internal state → `FilesTab.svelte`
- Notes tab list UI state → `NotesTab.svelte`
- Overview tab content → `OverviewTab.svelte`
## Component Communication
### Props (parent → child)
Data flows down via Svelte `export let prop`
### Events (child → parent)
Children dispatch events via `createEventDispatcher()`
- Suffix `:` in event names means handler in template: `on:openNote={handler}`, NOT call on mount
- All events should use `e.detail` to pass data
### Public API (bind:this)
Parent gets imperative handle via `bind:this={ref}` and calls:
- `ref.publicMethod(args)` — guard with optional chaining: `ref?.publicMethod?.(args)`
- Methods are exposed via `export function` in child component
## Services (Wails API)
All backend calls go through the `wailsCall()` helper in App.svelte:
```js
function wailsCall(method, ...args) {
// Returns Promise, rejects with 'Wails not connected: <method>' if backend unavailable
}
```
Key backend methods:
- **Startup**: `GetStartupStatus`, `VerstakVersion`, `ListSystemViewsWithPlugins`, `ListWorkspaceTree`, `ListEnabledTemplates`
- **Nodes**: `GetNodeDetail`, `ListItems`, `ListWorkspaceChildren`, `CreateNodeFromTemplate`, `DeleteNode`, `MoveNode`, `RenameNode`
- **Notes**: `ListNotes`, `CreateNote`, `ReadNote`, `SaveNote`, `DeleteNote`, `RenameNote`
- **Files**: `ListFiles`, `DeleteFileOrFolder`, `CreateEmptyFile`, `DuplicateNode`, `OpenFile`, `OpenFolder`, `GetFileBase64`, `ReadFileText`, `PreviewImport`, `AddPathCopy`, `AddPathLink`
- **Worklog**: `ListWorklog`, `CreateWorklogFull`, `UpdateWorklogEntry`, `DeleteWorklogEntry`, `AcceptSuggestionFull/With`, `GetSuggestions`
- **Inbox**: `ListInboxNodes`, `Capture*WithContext`, `DeleteInboxNode`, `ResolveInboxNode`
- **Sync**: `SyncStatus`, `SyncNow`, `TrashCount`, `ListTrash`
- **Logging**: `FrontendLog(level, message, stack)`, `LogStartupStep(step, success, detail)`
## Logging & Diagnostics
### Frontend errors
- Global `window.addEventListener('error', ...)` catches uncaught exceptions
- Global `window.addEventListener('unhandledrejection', ...)` catches broken promises
- Both log to `console.error` and forward to `FrontendLog()` backend binding
- Fatal mount error: renders error message inline in `#app` div (blank window becomes diagnostic)
### Backend logs
- Application log file: `~/.local/state/verstak/logs/verstak.log` (Linux)
- Fallback: `~/.config/verstak/logs/verstak.log`
- Format: `[timestamp] [level] message`
- Go `log.Printf()` output goes to stderr (visible in dev console)
### Vault debug log
- `<vault>/.verstak/debug.log` — written by `WriteDebugLog` binding (existing feature)
## Refactor Rules
### After each extraction step:
1. `grep` App.svelte for state that moved to child — must be zero hits or documented exceptions
2. `npm run build` — must pass
3. `go test ./...` — must pass
4. Manual GUI check:
- Window is not blank/white
- Sidebar shows (system views + workspace tree)
- No infinite "Загрузка..." on welcome screen
- Clicking system sections works
- Opening a case shows tabs
### Forbidden patterns:
- Child component state referenced directly in App.svelte after extraction
- `lang="ts"` in Svelte files (svelte-preprocess not installed)
- `[]string` passed directly to Wails bindings (must JSON-serialize)
- Modal without `role="dialog" aria-modal="true"` and Escape handler
- Buttons without `type="button"` in forms
- `resize: none` on textareas that should be resizable (use `resize: vertical`)
## Troubleshooting
### Blank window, no errors in terminal
1. Check browser DevTools console (F12 on Windows/Linux, Cmd+Alt+I on macOS)
2. In Wails dev mode: right-click → Inspect → Console
3. Look for `[Frontend Error]` messages
4. Check application log: `~/.local/state/verstak/logs/verstak.log`
### Stuck on "Загрузка..."
- `GetStartupStatus` likely failed or returned unexpected status
- Check network calls in DevTools Network tab
- Check backend log for startup errors
### Wails method not found
- Ensure method is registered in `bindings*.go` files
- Ensure method is exported (capitalized name)
- Ensure `wailsCall()` helper is used (not direct window access)
### Child component state moved but App still references old variable
- This causes a silent `undefined` reference or runtime crash
- Run `grep -n 'oldStateName' App.svelte` after each extraction
- Use `ref?.method?.()` for all public API calls on child components

View File

@ -1,5 +1,48 @@
import App from './App.svelte' import App from './App.svelte'
// ===== Global frontend error diagnostics =====
// These catch runtime errors that would otherwise cause a silent blank window.
window.addEventListener('error', (event) => {
const msg = event.error ? String(event.error) : event.message
const stack = event.error && event.error.stack ? event.error.stack : ''
console.error('[Frontend Error]', msg, stack)
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App'] && typeof window['go']['main']['App']['FrontendLog'] === 'function') {
window['go']['main']['App']['FrontendLog']('error', msg, stack)
}
} catch (e) {
// Backend not ready — ignore
}
})
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
const msg = reason instanceof Error ? String(reason) : JSON.stringify(reason)
const stack = reason instanceof Error && reason.stack ? reason.stack : ''
console.error('[Unhandled Promise Rejection]', msg, stack)
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App'] && typeof window['go']['main']['App']['FrontendLog'] === 'function') {
window['go']['main']['App']['FrontendLog']('error', 'Unhandled rejection: ' + msg, stack)
}
} catch (e) {
// Backend not ready — ignore
}
})
// Mount App with error fallback
try {
new App({ new App({
target: document.getElementById('app') target: document.getElementById('app')
}) })
} catch (e) {
console.error('[Fatal] App mount failed:', e)
const el = document.getElementById('app')
if (el) {
el.innerHTML = '<div style="padding:24px;color:#ff6b6b;font-family:monospace;white-space:pre-wrap">'
+ '<h2>Frontend Runtime Error</h2>'
+ '<p>' + String(e) + '</p>'
+ (e.stack ? '<pre>' + e.stack + '</pre>' : '')
+ '</div>'
}
}

View File

@ -243,5 +243,25 @@ export function CountActivityByNode(arg1) {
} }
export function CreateEmptyFile(arg1, arg2) { export function CreateEmptyFile(arg1, arg2) {
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2); return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2)
}
// ===== Logging & Diagnostics =====
export function FrontendLog(level, message, stack) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
return window['go']['main']['App']['FrontendLog'](level, message, stack || '')
}
} catch (e) {}
return Promise.resolve()
}
export function LogStartupStep(step, success, detail) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
return window['go']['main']['App']['LogStartupStep'](step, success, detail || '')
}
} catch (e) {}
return Promise.resolve()
} }