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:
parent
bfe57ac0ac
commit
df21340402
|
|
@ -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)
|
||||||
|
}
|
||||||
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-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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,48 @@
|
||||||
import App from './App.svelte'
|
import App from './App.svelte'
|
||||||
|
|
||||||
new App({
|
// ===== Global frontend error diagnostics =====
|
||||||
target: document.getElementById('app')
|
// 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({
|
||||||
|
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>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue