fix: vault init on startup; add nil guards to all bindings; fix SA_ONSTACK signal crash; deduplicate settings button; add i18n for vault error

This commit is contained in:
mirivlad 2026-06-04 00:37:14 +08:00
parent f92394e3d7
commit a69dc845e6
26 changed files with 259 additions and 21 deletions

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"sync"
@ -44,6 +45,15 @@ type App struct {
vault string
}
// requireVault returns an error if no vault is open and services are not initialized.
// All binding methods that access vault services MUST call this first.
func (a *App) requireVault() error {
if !a.IsReady() {
return fmt.Errorf("vault not open")
}
return nil
}
// startup is called when the app starts. Store context and wire drag-and-drop.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx

View File

@ -5,6 +5,9 @@ import (
)
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.actions.ListByNode(nodeID)
if err != nil {
return nil, err
@ -27,6 +30,9 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
}
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
@ -42,11 +48,17 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
}
func (a *App) DeleteAction(id string) error {
if err := a.requireVault(); err != nil {
return err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
return a.actions.Delete(id)
}
func (a *App) RunAction(id string) error {
if err := a.requireVault(); err != nil {
return err
}
_, err := a.actions.Run(id)
return err
}

View File

@ -25,6 +25,9 @@ func (a *App) ListSystemViews() []SystemViewDTO {
}
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
aeByParent = nil
@ -144,6 +147,9 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
}
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListRecent(limit, offset)
if err != nil {
return nil, err
@ -156,6 +162,9 @@ func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
}
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListByNode(nodeID, limit, offset)
if err != nil {
return nil, err
@ -168,6 +177,9 @@ func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO,
}
func (a *App) CountActivityByNode(nodeID string) (int, error) {
if err := a.requireVault(); err != nil {
return 0, err
}
return a.activity.CountByNode(nodeID)
}

View File

@ -78,6 +78,16 @@ func (a *App) GetStartupStatus() (*StartupStatus, error) {
}, nil
}
// Initialize services so that the vault is ready for use
if err := a.initVault(appCfg.VaultPath); err != nil {
return &StartupStatus{
Status: "recovery",
VaultPath: appCfg.VaultPath,
DefaultPath: defaultPath,
Error: fmt.Sprintf("init vault: %v", err),
}, nil
}
return &StartupStatus{
Status: "ready",
VaultPath: appCfg.VaultPath,

View File

@ -10,6 +10,9 @@ import (
// WriteDebugLog appends a line to <vault>/.verstak/debug.log.
// Called from frontend to log JS-side diagnostics in production GUI builds.
func (a *App) WriteDebugLog(msg string) {
if !a.IsReady() {
return
}
logPath := filepath.Join(a.vault, ".verstak", "debug.log")
line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02T15:04:05"), msg)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)

View File

@ -8,6 +8,9 @@ import (
)
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
@ -30,6 +33,9 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
}
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
@ -61,6 +67,9 @@ func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
@ -73,6 +82,9 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
@ -85,6 +97,9 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
@ -108,6 +123,9 @@ func (a *App) DeleteFileOrFolder(nodeID string) error {
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
@ -119,6 +137,9 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
@ -139,5 +160,8 @@ func (a *App) ValidateName(name string) error {
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.files.PreviewImport(sourcePath)
}

View File

@ -15,6 +15,9 @@ import (
)
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListRoots(false)
if err != nil {
return nil, err
@ -31,6 +34,9 @@ func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
}
func (a *App) ListWorkspaceChildren(parentID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
@ -65,6 +71,9 @@ func isContainerType(typ string) bool {
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
@ -73,6 +82,9 @@ func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
}
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
@ -82,6 +94,9 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
}
func (a *App) GetNodeTitle(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return "", err
@ -90,6 +105,9 @@ func (a *App) GetNodeTitle(nodeID string) (string, error) {
}
func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
tmpl, ok := a.templates.Get(templateID)
if !ok {
return nil, fmt.Errorf("template %q not found", templateID)
@ -246,6 +264,9 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
}
func (a *App) DeleteNode(id string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(id)
if err != nil {
return a.nodes.SoftDelete(id)
@ -282,6 +303,9 @@ func (a *App) DeleteNode(id string) error {
}
func (a *App) RenameNode(nodeID, newTitle string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
@ -574,6 +598,9 @@ func (a *App) wouldCreateCycle(nodeID, newParentID string) error {
}
func (a *App) MoveNode(nodeID, newParentID string) error {
if err := a.requireVault(); err != nil {
return err
}
if nodeID == "" {
return fmt.Errorf("node ID is required")
}
@ -918,6 +945,9 @@ type SearchNodeResult struct {
}
func (a *App) SearchNodes(query string) ([]SearchNodeResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
nodes, err := a.nodes.Search(query, 20)
if err != nil {
return nil, err
@ -939,6 +969,9 @@ func (a *App) SearchNodes(query string) ([]SearchNodeResult, error) {
}
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return "", err

View File

@ -9,6 +9,9 @@ import (
)
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
@ -23,6 +26,9 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
}
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
node, fileRec, err := a.notes.Create(parentID, title, "")
if err != nil {
return nil, err
@ -34,10 +40,16 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
}
func (a *App) ReadNote(noteID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.notes.Read(noteID)
}
func (a *App) SaveNote(noteID, content string) error {
if err := a.requireVault(); err != nil {
return err
}
if err := a.notes.Save(noteID, content); err != nil {
return err
}

View File

@ -95,6 +95,9 @@ func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
}
func (a *App) ListTemplates() []TemplateDTO {
if !a.IsReady() {
return nil
}
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
@ -109,6 +112,9 @@ func (a *App) ListTemplates() []TemplateDTO {
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
@ -166,18 +172,30 @@ func (a *App) PickDirectory() (string, error) {
}
func (a *App) OpenFile(fileID string) error {
if err := a.requireVault(); err != nil {
return err
}
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
if err := a.requireVault(); err != nil {
return err
}
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
@ -214,6 +232,9 @@ func (a *App) OpenVaultFolder() error {
}
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if query == "" {
return []SearchResultDTO{}, nil
}

View File

@ -15,6 +15,9 @@ import (
// GetSuggestions analyzes today's activity and returns conservative suggestions.
// Only events not already linked in worklog_entry_events are considered.
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListTodayEvents()
if err != nil || len(events) == 0 {
return nil, err
@ -114,12 +117,18 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDsJSON)
}
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
d := date
if d == "" {
d = time.Now().Format("2006-01-02")

View File

@ -114,6 +114,9 @@ type SyncSettingsDTO struct {
}
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
@ -133,6 +136,9 @@ func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
if err := a.requireVault(); err != nil {
return err
}
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
@ -165,6 +171,9 @@ func (a *App) SyncConfigure(serverURL, username, password string) error {
}
func (a *App) SyncDisconnect() error {
if err := a.requireVault(); err != nil {
return err
}
deviceToken := config.LoadDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
@ -195,6 +204,9 @@ func (a *App) SyncTestConnection(serverURL, username, password string) error {
}
func (a *App) SyncSetInterval(minutes int) error {
if err := a.requireVault(); err != nil {
return err
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
@ -207,6 +219,9 @@ func (a *App) SyncSetInterval(minutes int) error {
}
func (a *App) SyncNow() (map[string]interface{}, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
deviceToken := config.LoadDeviceToken(a.vault)
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
@ -316,6 +331,9 @@ func (a *App) updateSyncSuccess(lastSyncAt string) error {
// CheckSyncConnection tests the current sync connection.
func (a *App) CheckSyncConnection() (bool, string) {
if !a.IsReady() {
return false, "vault not open"
}
appCfg, _ := config.LoadAppConfig()
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
return false, "sync not configured"
@ -338,6 +356,9 @@ func (a *App) CheckSyncConnection() (bool, string) {
// ResetSyncKey clears the device token and resets sync state.
func (a *App) ResetSyncKey() error {
if err := a.requireVault(); err != nil {
return err
}
config.RemoveDeviceToken(a.vault)
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {

View File

@ -11,6 +11,9 @@ import (
)
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.worklog.ListByNode(nodeID)
if err != nil {
return nil, err
@ -23,6 +26,9 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
}
func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if date == "" {
entry, err := a.worklog.Add(nodeID, summary, details, minutes, approximate, billable)
if err != nil {
@ -42,6 +48,9 @@ func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes i
// --- report bindings ---
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
rows, err := a.worklog.ListReport(f)
if err != nil {
@ -52,21 +61,33 @@ func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren
}
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (*worklog.ReportSummary, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.Summary(f)
}
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportCSV(f)
}
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportMarkdown(f)
}
func (a *App) ExportWorklogPDF(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]byte, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportPDF(f)
}
@ -97,6 +118,9 @@ func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, b
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
var data []byte
@ -159,6 +183,9 @@ func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, include
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
rows, err := a.db.Query(
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
e.title, COALESCE(e.metadata,''), e.created_at

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

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;
}
</style>
<script type="module" crossorigin src="/assets/main-DS67FqQ2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-oJnEtKWF.css">
<script type="module" crossorigin src="/assets/main-CDRB1gNP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BctNikp7.css">
</head>
<body>
<div id="app"></div>

View File

@ -17,6 +17,9 @@ var assets embed.FS
func main() {
app := &App{}
// Fix WebKit signal handler for Go 1.24+ compatibility
ensureSignalOnStack()
err := wails.Run(&options.App{
Title: "Верстак",
Width: 1280,

38
cmd/verstak-gui/sigfix.go Normal file
View File

@ -0,0 +1,38 @@
package main
/*
#define _GNU_SOURCE
#include <signal.h>
#include <stdbool.h>
// fixSigsegvOnStack adds SA_ONSTACK to the current SIGSEGV handler.
// Go 1.24+ requires all signal handlers to have SA_ONSTACK set,
// but WebKit/JavaScriptCore installs a SIGSEGV handler without it.
void fixSigsegvOnStack(void) {
struct sigaction act;
if (sigaction(SIGSEGV, NULL, &act) == 0) {
if (!(act.sa_flags & SA_ONSTACK)) {
act.sa_flags |= SA_ONSTACK;
sigaction(SIGSEGV, &act, NULL);
}
}
}
*/
import "C"
import "time"
// ensureSignalOnStack periodically ensures SIGSEGV handler has SA_ONSTACK.
// This is needed because WebKit/JavaScriptCore installs a SIGSEGV handler
// without SA_ONSTACK, which causes Go 1.24+ to crash with:
// "non-Go code set up signal handler without SA_ONSTACK flag"
func ensureSignalOnStack() {
// Apply once after a short delay to let WebKit initialize
go func() {
// Retry a few times since WebKit may re-install its handler
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond)
C.fixSigsegvOnStack()
}
}()
}

View File

@ -25,6 +25,9 @@ type VaultCheckResult struct {
}
func (a *App) VaultCheck() (*VaultCheckResult, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
result := &VaultCheckResult{Healthy: true}
// Build a set of all node IDs for ancestor check

View File

@ -13,6 +13,9 @@ import (
// parent-child relationships and creates human-readable folders in the vault.
// It performs a dry-run if dryRun is true.
func (a *App) MigrateVaultLayout(dryRun bool) (*MigrationReport, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
report := &MigrationReport{}
// Load all nodes

View File

@ -60,6 +60,12 @@
let caseActivity = []
let version = ''
let error = ''
function translateError(msg) {
const map = {
'vault not open': t('error.vaultNotOpen'),
}
return map[msg] || msg
}
let selectedSection = ''
let selectedNode = null
let activeTab = 'overview'
@ -1518,8 +1524,8 @@
<SyncStatus {syncStatus} {syncLoading} onSync={runSyncNow} onOpenSettings={() => openSettings('sync')} />
<div class="sidebar-footer-row">
<button class="sidebar-settings-btn" on:click={() => openSettings()} title={t('common.settings')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="2.8"/><path d="M12 1.5v1.9a1.8 1.8 0 0 0 1.6 1.77 1.8 1.8 0 0 0 1.74-.67l1.23-1.45a9 9 0 0 1 3.54 2.04l-.96 1.6a1.8 1.8 0 0 0 .2 2.08 1.8 1.8 0 0 0 1.98.49l1.75-.58a9 9 0 0 1 .68 4.09l-1.86.6a1.8 1.8 0 0 0-1.16 1.66 1.8 1.8 0 0 0 .93 1.6l.93.57a9 9 0 0 1-2.26 3.42l-1.32-1a1.8 1.8 0 0 0-2.1-.15 1.8 1.8 0 0 0-.87 1.55V22.5a9 9 0 0 1-4.1.01v-1.93a1.8 1.8 0 0 0-.93-1.56 1.8 1.8 0 0 0-2.1.16l-1.3.98a9 9 0 0 1-3.48-2.09l.92-1.54a1.8 1.8 0 0 0-.96-2.6 1.8 1.8 0 0 0-2.08.5l-.98 1.2a9 9 0 0 1-2.5-3.22l1.7-.67a1.8 1.8 0 0 0-1.7-2.51 1.8 1.8 0 0 0-.4.05L1.4 9.56a9 9 0 0 1 .22-4.12l1.72.68a1.8 1.8 0 0 0 2.1-.42 1.8 1.8 0 0 0 .22-2.03L4.6 2.34A9 9 0 0 1 8.84.38l.98 1.6a1.8 1.8 0 0 0 1.74.94A1.8 1.8 0 0 0 13 1.47V1.5z"/>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<span class="version">{version}</span>
@ -1541,17 +1547,12 @@
{/if}
</div>
<div class="header-right">
<button class="header-settings-btn" on:click={() => openSettings()} title={t('common.settings')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="2.8"/><path d="M12 1.5v1.9a1.8 1.8 0 0 0 1.6 1.77 1.8 1.8 0 0 0 1.74-.67l1.23-1.45a9 9 0 0 1 3.54 2.04l-.96 1.6a1.8 1.8 0 0 0 .2 2.08 1.8 1.8 0 0 0 1.98.49l1.75-.58a9 9 0 0 1 .68 4.09l-1.86.6a1.8 1.8 0 0 0-1.16 1.66 1.8 1.8 0 0 0 .93 1.6l.93.57a9 9 0 0 1-2.26 3.42l-1.32-1a1.8 1.8 0 0 0-2.1-.15 1.8 1.8 0 0 0-.87 1.55V22.5a9 9 0 0 1-4.1.01v-1.93a1.8 1.8 0 0 0-.93-1.56 1.8 1.8 0 0 0-2.1.16l-1.3.98a9 9 0 0 1-3.48-2.09l.92-1.54a1.8 1.8 0 0 0-.96-2.6 1.8 1.8 0 0 0-2.08.5l-.98 1.2a9 9 0 0 1-2.5-3.22l1.7-.67a1.8 1.8 0 0 0-1.7-2.51 1.8 1.8 0 0 0-.4.05L1.4 9.56a9 9 0 0 1 .22-4.12l1.72.68a1.8 1.8 0 0 0 2.1-.42 1.8 1.8 0 0 0 .22-2.03L4.6 2.34A9 9 0 0 1 8.84.38l.98 1.6a1.8 1.8 0 0 0 1.74.94A1.8 1.8 0 0 0 13 1.47V1.5z"/>
</svg>
</button>
</div>
</header>
{#if error}
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
{error}
{translateError(error)}
<button class="dismiss-btn" on:click|stopPropagation={() => error = ''} aria-label="Dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@ -2512,9 +2513,7 @@
.sidebar-settings-btn { background: transparent; border: none; border-radius: 6px; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; font-family: inherit; width: 32px; height: 32px; }
.sidebar-settings-btn:hover { background: #1e1e38; color: #a5b4fc; }
.sidebar-settings-btn:active { background: #252545; color: #818cf8; }
.header-settings-btn { background: transparent; border: none; border-radius: 6px; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; font-family: inherit; width: 32px; height: 32px; }
.header-settings-btn:hover { background: #1e1e38; color: #a5b4fc; }
.header-settings-btn:active { background: #252545; color: #818cf8; }
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }

View File

@ -120,6 +120,7 @@ export default {
'error.generic': 'An error occurred',
'error.invalidCredentials': 'Invalid username or password',
'error.vaultNotOpen': 'Vault not open',
'worklog.suggestions': 'Suggestions for today',
'worklog.apply': 'Apply',

View File

@ -325,6 +325,7 @@ export default {
'error.nameEmpty': 'Имя не может быть пустым',
'error.nameInvalid': 'Недопустимое имя',
'error.selectCaseFirst': 'Сначала выберите дело',
'error.vaultNotOpen': 'Хранилище не открыто',
'common.open': 'Открыть',
'delete.files': 'файлов ({count})',
'file.namePrompt': 'Введите имя файла:',