Author SHA1 Message Date
mirivladandOWL 120ff7d6fe refactor: unified EditorPanel for notes and files, SaveFileText backend
- Create EditorPanel.svelte: shared editor component for notes + files
  - Markdown: edit/preview/split modes via MarkdownEditor + MarkdownPreview
  - Plain text: monospace textarea
  - Header: title, mode switcher, external open button
  - Footer: save + close buttons, dirty indicator
- Rewrite NoteEditorPanel.svelte as thin wrapper over EditorPanel
- FilesTab: browser/editor modes (editor replaces file list, not appended)
- Add SaveFileText Wails binding: writes text files atomically (tmp+rename)
- Add WriteText to files.Service with SHA256 update
- Remove separate TextFileEditor.svelte (replaced by EditorPanel)
- Note context: SaveNote API, noteId/title props
- File context: SaveFileText API, fileId prop
- Linked .md (CheckFileAction=note) → note editor tab
- Unlinked .md / text files → EditorPanel in FilesTab
- Images/PDF/binary → FilePreviewModal only

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
2026-06-16 10:40:09 +08:00
mirivlad 383a9546df fix: inline file editor mode in FilesTab, remove separate TextFileEditor
- Replace separate TextFileEditor modal with inline editor mode in FilesTab
- Editor mode replaces browser mode (file list) within the same .files-tab container
- Markdown files: view/edit/split modes using existing MarkdownEditor + MarkdownPreview
- Plain text files: readonly monospace viewer
- Linked .md notes → note editor (via CheckFileAction)
- Unlinked .md / text files → inline editor (not FilePreviewModal)
- FilePreviewModal only for images, PDF, binary
- Back button returns to file browser without losing folder state
- Reset editor state on selectedNode change
- Remove TextFileEditor.svelte (no longer needed)

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
)
2026-06-16 09:51:25 +08:00
mirivladandOWL d7d806530b feat: TextFileEditor for text/markdown files, fix menu positioning
- Create TextFileEditor.svelte: full editor/viewer for text files
  - Markdown: edit/preview/split modes with MarkdownEditor + MarkdownPreview
  - Plain text: readonly monospace viewer
  - Footer: external open button + save (markdown) + close
- Rewrite _openFile in FilesTab: text/markdown → TextFileEditor, images/PDF → FilePreviewModal
- Linked .md notes → note editor (via CheckFileAction)
- Unlinked .md / text files → TextFileEditor (not FilePreviewModal)
- Fix toggleMenu in FileTreeRow: position using getBoundingClientRect
- Expand codeNames/textMimes for all text file types
- Add debug logs for file open flow and menu positioning

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
2026-06-16 09:26:44 +08:00
mirivladandOWL 82f59ab8da fix: FilesTab empty state, button styles, text preview, menu positioning
- Add reactive $: block in FilesTab to auto-load files on selectedNode change
- Copy .btn/.btn-primary/.btn-sm styles into NotesTab.svelte and FilesTab.svelte (scoped CSS)
- Fix FilePreviewModal: render .md with MarkdownPreview, text files in <pre><code>, external open in footer
- Expand codeNames/textMimes in fileUtils.js to cover all text file types
- Add fallback text read for unknown file types in _openPreview
- Fix toggleMenu in FileTreeRow: position menu near button using getBoundingClientRect
- Add debug logs for file open flow and menu positioning
- Add tabindex+keydown to MarkdownPreview div (a11y partial fix)

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
2026-06-16 09:08:13 +08:00
mirivlad 208cd970d7 refactor(frontend): extract OverviewTab safely
- New component: frontend/src/lib/components/OverviewTab.svelte
  - Props: selectedNode, notes, worklog, formatDate, nodeKindLabel
  - Events: createNote, addFile, createAction, switchTab, openNote
  - Pure display component, no internal state

- App.svelte changes:
  - Replaced inline overview markup with <OverviewTab>
  - Removed overview-related CSS (overview, meta-grid, qa-btn, etc.)
  - openCreateNote() call replaced with setActiveTab('notes') only
    (create note is now handled by NotesTab component)

- Build: npm run build , go test ./... 
2026-06-16 03:29:18 +08:00
mirivlad 58cdd61d27 refactor(frontend): extract NotesTab safely
- New component: frontend/src/lib/components/notes/NotesTab.svelte
  - Owns notes list UI, create-note form, note cards with rename/delete
  - Props: notes array, formatDate
  - Events: submitCreateNote, openNote, startRename, deleteNote

- App.svelte changes:
  - Removed inline notes tab markup (replaced with <NotesTab>)
  - Removed showCreateNote, newNoteTitle state variables
  - Removed openCreateNote, cancelCreateNote, submitCreateNote functions
  - Added _handleSubmitCreateNote() event handler from NotesTab
  - Removed notes-related CSS (notes-tab, note-card, note-action-*)
  - Fixed import paths in FilesTab (../../ instead of ../ for lib/ files)

- Build: npm run build , go test ./... , build.sh gui 
2026-06-16 03:21:49 +08:00
mirivlad acdbbdfa55 refactor(frontend): extract FilesTab safely
- New component: frontend/src/lib/components/files/FilesTab.svelte
  - Owns all file tab state: loadingFiles, currentFolderId, folderStack,
    fileItems, preview*, clipboard, selectedIds, dragIds, importing, importSummary, etc.
  - Public API via bind:this: resetToNode(), addFile(), loadFolder(),
    openFileById(), focusItem(), handleFilesKeydown(), resetState()
  - Events: openNote, refreshParent, error
  - Inline modals: rename, confirm, import dialog, file preview

- App.svelte changes:
  - Removed all file-specific state variables and functions
  - Preview state renamed to trashPreview* (for trash preview only)
  - Files tab inline markup replaced with <FilesTab> component
  - Overview 'Add file' button delegates to filesTabRef.addFile()
  - openInboxArtifact, navigateToFile, openActivityTarget, openSearchResult
    delegate to filesTabRef for file operations
  - Node rename (tree context menu) uses separate openNodeRename/submitNodeRename
    functions (file rename is now in FilesTab)
  - closeTrashPreview for trash file preview modal

- Build: npm run build , go test ./... , build.sh gui 
2026-06-16 03:00:24 +08:00
mirivlad df21340402 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.
2026-06-16 02:22:44 +08:00
39 changed files with 1681 additions and 1330 deletions
+40
View File
@@ -210,6 +210,46 @@ func (a *App) CheckFileAction(fileID string) (*PreflightFileAction, error) {
return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil return &PreflightFileAction{Action: "preview", FileName: fileRec.Filename}, nil
} }
// SaveFileText saves text content to a file record.
// Only works for vault-contained text files (not binary).
func (a *App) SaveFileText(fileID string, content string) error {
if err := a.requireVault(); err != nil {
return err
}
fileRec, err := a.files.Get(fileID)
if err != nil {
return fmt.Errorf("get file: %w", err)
}
// Safety: only save text-like files
name := strings.ToLower(fileRec.Filename)
isText := strings.HasPrefix(fileRec.MIME, "text/") ||
strings.HasSuffix(name, ".md") ||
strings.HasSuffix(name, ".txt") ||
strings.HasSuffix(name, ".json") ||
strings.HasSuffix(name, ".yaml") ||
strings.HasSuffix(name, ".yml") ||
strings.HasSuffix(name, ".csv") ||
strings.HasSuffix(name, ".xml") ||
strings.HasSuffix(name, ".ini") ||
strings.HasSuffix(name, ".conf") ||
strings.HasSuffix(name, ".sh") ||
strings.HasSuffix(name, ".py") ||
strings.HasSuffix(name, ".js") ||
strings.HasSuffix(name, ".ts") ||
strings.HasSuffix(name, ".css") ||
strings.HasSuffix(name, ".html") ||
strings.HasSuffix(name, ".log")
if !isText {
return fmt.Errorf("refusing to save binary file: %s", fileRec.Filename)
}
if err := a.files.WriteText(fileRec, content); err != nil {
return fmt.Errorf("write file: %w", err)
}
// Record activity
_ = a.activity.Record(fileRec.NodeID, activity.TargetFile, fileID, "", activity.TypeFileModified, fileRec.Filename, "")
return nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) { func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
+89
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)
}
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
+2 -2
View File
@@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-C0D__Sxo.js"></script> <script type="module" crossorigin src="/assets/main-B99YW--H.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css"> <link rel="stylesheet" crossorigin href="/assets/main-CtgLvi_n.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+195 -140
View File
@@ -1,161 +1,216 @@
# Frontend Architecture # Frontend Architecture — Verstak GUI
## Overview ## Tech Stack
Verstak frontend is a Svelte 3 application running inside Wails v2 (Go bridge). - **Framework**: Svelte 4 (runes-free, compiler-only)
The app manages a hierarchical vault of nodes (folders/cases, notes, files, links, actions) - **Build tool**: Vite 5
with sync capabilities, worklog/journal, and activity tracking. - **GUI shell**: Wails v2 (Go backend + WebKit GTK frontend)
- **Language**: Plain JavaScript (no TypeScript in Svelte files — `lang="ts"` is NOT used)
## Technology Stack ## Source Structure
- **UI Framework:** Svelte 3 (plain JS, no TypeScript in components)
- **Desktop Bridge:** Wails v2 (`window.go.main.App.*`)
- **Bundler:** Vite (via Wails)
- **Markdown:** Custom renderer in `lib/markdown/`
- **i18n:** Custom lightweight system in `lib/i18n/`
- **Styling:** Scoped CSS in Svelte components, dark theme
## Directory Structure
``` ```
frontend/src/ frontend/src/
├── App.svelte # Root component (being modularised) main.js # Entry: mounts App.svelte, global error handlers
├── TreeNode.svelte # Tree node for sidebar (inline) App.svelte # Root component: sidebar, header, modals, tab router
├── FileTreeRow.svelte # File row in file tab (inline) FileTreeRow.svelte # Sidebar tree node row
├── wailsjs/go/main/App.js # Auto-generated Wails bindings TreeNode.svelte # Recursive tree node
├── lib/ lib/
├── components/ # Reusable UI components AppHeader.svelte # Top bar with title, search, actions
│ │ └── notes/ BrowserEvents.svelte # Browser extension events panel
│ │ ├── NoteEditorPanel.svelte CalendarPluginPage.svelte # Plugin page host
│ │ ├── MarkdownEditor.svelte ConfirmModal.svelte # Reusable confirm dialog
│ │ ├── MarkdownPreview.svelte FileBreadcrumbs.svelte # File tab breadcrumb navigation
│ │ ├── InternalLinkPicker.svelte FileIcon.svelte # Icon resolver for file types
│ │ └── ObjectPickerModal.svelte FilePreviewModal.svelte # File content preview modal
│ ├── services/ # API/Data access layer FirstRun.svelte # First-run wizard
│ │ ├── wails.js # Base Wails call helper GlobalSearch.svelte # Global search bar + results
│ │ ├── notes.js # Notes API SettingsWindow.svelte # Settings modal container
│ │ ├── files.js # Files API Settings*.svelte # Settings sub-sections (General, Sync, etc.)
│ │ ├── search.js # Search API SyncStatus.svelte # Sync status badge
│ │ ├── inbox.js # Inbox API TemplateIcon.svelte # Template type icon
│ │ ├── trash.js # Trash API TodayScreen.svelte # Today dashboard
│ │ ├── sync.js # Sync API VaultRecovery.svelte # Vault recovery wizard
│ │ ├── journal.js # Journal/Worklog API actionIcons.js # SVG icon strings
│ ├── actions.js # Actions API fileUtils.js # File type helpers (canPreview, isMarkdown, etc.)
│ │ ├── links.js # Links API i18n/ # Internationalization (en, ru)
│ │ └── activity.js # Activity API markdown/ # Markdown rendering + internal links
│ ├── state/ # State management (planned) util/ # Keyboard layout helper
│ │ ├── navigation.js # Navigation state components/
│ │ └── uiState.js # UI state OverviewTab.svelte # Overview tab: meta, quick actions, recent items
│ ├── markdown/ # Markdown processing files/
│ │ ├── markdown.ts FilesTab.svelte # Files tab: file browser, preview, import, rename
│ │ └── internalLinks.ts notes/
│ ├── i18n/ # Internationalisation NotesTab.svelte # Notes tab: note list, create form
│ │ ├── index.js MarkdownEditor.svelte # Markdown textarea with toolbar
│ │ └── locales/ MarkdownPreview.svelte # Rendered markdown preview
│ │ ├── en.js NoteEditorPanel.svelte # Editor + preview layout, public API: insertText()
│ │ └── ru.js InternalLinkPicker.svelte # Object picker for verstak:// links
│ ├── util/ # Utilities ObjectPickerModal.svelte # Legacy object picker modal
│ │ ├── keyboardLayout.ts
│ │ └── markdown.test.js
│ ├── AppHeader.svelte
│ ├── GlobalSearch.svelte
│ ├── FileBreadcrumbs.svelte
│ ├── FileIcon.svelte
│ ├── FilePreviewModal.svelte
│ ├── ConfirmModal.svelte
│ ├── TodayScreen.svelte
│ ├── BrowserEvents.svelte
│ ├── FirstRun.svelte
│ ├── VaultRecovery.svelte
│ ├── SyncStatus.svelte
│ ├── TemplateIcon.svelte
│ ├── CalendarPluginPage.svelte
│ ├── SettingsWindow.svelte
│ ├── SettingsSidebar.svelte
│ ├── SettingsGeneral.svelte
│ ├── SettingsSync.svelte
│ ├── SettingsPlugins.svelte
│ ├── SettingsBrowserBridge.svelte
│ ├── SettingsWorkspace.svelte
│ ├── SettingsTemplates.svelte
│ ├── SettingsFiles.svelte
│ ├── SettingsBackup.svelte
│ ├── SettingsActivity.svelte
│ ├── actionIcons.js
│ └── fileUtils.js
``` ```
## Wails Bridge ## Role of App.svelte
All backend calls go through `window.go.main.App[method](...)`. `App.svelte` is the **root component**. It owns:
The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
## Planned Components (to extract from App.svelte) 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, node rename, import (removed — now in FilesTab), worklog, inbox assign, link edit, settings, trash preview
4. **Cross-cutting concerns**: navigation history (goBack, rememberNavigation), keyboard shortcuts, drag-and-drop orchestration, capture/inbox flow
5. **Note editor lifecycle**: `noteEditor` state, `doOpenNote()`, `saveCurrentNote()`, `closeNoteEditor()`, link modal, internal link picker
### Layout App.svelte is **NOT** responsible for:
- `AppShell.svelte` — root layout wrapper - **Files tab**`FilesTab.svelte` (owns all file browser state, preview, import, rename)
- `Sidebar.svelte` — navigation sidebar - **Notes tab list**`NotesTab.svelte` (owns note list UI, create form; editor stays in App)
- `MainWorkspace.svelte` — main content area - **Overview tab**`OverviewTab.svelte` (pure display: meta, quick actions, recent items)
- **Settings sections** → each has its own `Settings*.svelte`
### Pages/Tab Content ## Component Communication
- `OverviewTab.svelte` — node overview with meta and quick actions
- `NotesTab.svelte` — notes list and creation
- `FilesTab.svelte` — file browser with breadcrumbs
- `InboxContent.svelte` + `InboxFullScreen.svelte`
- `LinksTab.svelte`
- `ActionsTab.svelte`
- `WorklogTab.svelte`
- `ActivityTabContent.svelte`
- `TrashContent.svelte`
- `JournalScreen.svelte`
- `ActivityFeedScreen.svelte`
- `WelcomeScreen.svelte`
### Modals ### Props (parent → child)
- `CreateNodeModal.svelte` Data flows down via Svelte `export let prop`
- `WorklogModal.svelte`
- `CreateActionModal.svelte`
- `ImportModal.svelte`
- `RenameModal.svelte`
- `AssignInboxModal.svelte`
- `EditLinkModal.svelte`
- `LinkInsertModal.svelte`
- `NoteRenameModal.svelte`
- `ContextMenu.svelte`
## Data Flow ### Events (child → parent)
Children dispatch events via `createEventDispatcher()`
1. User interacts with UI component ### Public API (bind:this)
2. Component calls a service function (e.g., `notesApi.createNote(...)`) Parent gets imperative handle via `bind:this={ref}` and calls:
3. Service calls `wailsCall('CreateNote', ...)` - `ref.publicMethod(args)` — guard with optional chaining: `ref?.method?.(args)`
4. Wails bridge forwards to Go backend
5. Go backend returns result → Wails → service → component updates state
## State Management ## Component Reference
Currently all state lives in App.svelte as local variables. ### OverviewTab (`lib/components/OverviewTab.svelte`)
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`. **Props**: `selectedNode`, `notes`, `worklog`, `formatDate`, `nodeKindLabel`
**Events**: `createNote`, `addFile`, `createAction`, `switchTab`, `openNote`
**State**: None (pure display)
### Files Flow ### NotesTab (`lib/components/notes/NotesTab.svelte`)
**Props**: `notes`, `formatDate`
**Events**: `submitCreateNote`({title}), `openNote`({note}), `startRename`({noteId, currentTitle}), `deleteNote`({note})
**State**: `showCreateNote`, `newNoteTitle`
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser ### FilesTab (`lib/components/files/FilesTab.svelte`)
- **API services:** `lib/services/files.js`, `lib/services/nodes.js` **Props**: `selectedNode`, `wailsCall`
- **Events emitted:** **Events**: `openNote`({id, title}), `refreshParent`({nodeId}), `error`({message})
- `on:openNote` — when a .md file linked to a note is opened **Public API** (via bind:this):
- `on:refreshParent` — after file operations that modify the tree - `resetToNode(nodeId)` — reset to root of given node
- `on:error` — on operation failures - `addFile()` — open file picker and import
- `on:rename` — requests parent to show rename modal - `loadFolder(folderId)` — load folder contents
- `on:confirm` — requests parent to show confirm dialog - `openFileById(fileNodeId)` — find and preview file
- **Public methods:** - `focusItem(nodeId)` — select item by ID
- `resetToNode(nodeId)` — reset state when selected node changes - `handleFilesKeydown(e)` — delegate keyboard handling
- `filesHandleKeydown(e)` — keyboard handler for files tab - `resetState()` — full reset (on node change)
- **.md → note editor flow:** Handled inside FilesTab via `CheckFileAction` Wails call. If action is 'note', emits `openNote`. If 'external', opens in system viewer. Otherwise shows built-in preview.
- **File preview:** `FilePreviewModal.svelte` (already existed), invoked by FilesTab
- **Import dialog:** Inline in FilesTab template (moved from App.svelte)
## Build & Verification **Internal state** (owned by FilesTab, NOT accessible from App):
- `loadingFiles`, `currentFolderId`, `folderStack`, `fileItems`
- `previewItem`, `previewContent`, `previewLoading`, `previewError`
- `clipboard`, `selectedIds`, `dragIds`
- `importing`, `importSummary`, `showImportDialog`, `pendingImportPath`, `pendingImportParent`
- `showRename`, `renameId`, `renameValue`, `renameError`
- `showConfirm`, `confirmTitle`, `confirmMessage`, `confirmAction`, `cancelAction`
- `npm run build` in `frontend/` directory ## State Ownership Rules
- `go test ./...` from project root
- `bash scripts/build.sh gui` for full GUI binary ### App.svelte MUST own:
- Manual smoke testing via Wails dev server - `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`
- Trash preview: `trashPreviewItem`, `trashPreviewContent`, `trashPreviewLoading`, `trashPreviewError`
- Journal state: `journalRows`, `journalSummary`, filters
- Worklog modal state: `showWorklogModal`, `wlModal*`
- Inbox: `inboxNodes`, `localInboxNodes`, capture state
- Links: `links`
- Sync: `syncStatus`
- `error` (global error banner)
- `notes` (loaded by `loadTabData`, passed to NotesTab and OverviewTab as prop)
- `worklog` (loaded by `loadTabData`, passed to OverviewTab as prop)
### App.svelte must NOT directly reference:
- Files tab internal state (all in FilesTab)
- Notes tab create form state (showCreateNote, newNoteTitle — in NotesTab)
- Any `fileItems`, `selectedIds`, `currentFolderId`, `folderStack`, etc.
## 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
-107
View File
@@ -1,107 +0,0 @@
# Frontend Change Map
## Purpose
This document tracks the refactoring of `App.svelte` from a 4794-line monolith
into a modular frontend architecture. Each step preserves behaviour exactly.
## Phase 1: Documentation & Foundation
- [x] Audit App.svelte (all 4794 lines read and mapped)
- [x] Create `docs/frontend-architecture.md`
- [x] Create `docs/frontend-change-map.md`
## Phase 2: API Layer
Extract all Wails calls into service modules.
- [x] Create `lib/services/wails.js` — base `wailsCall` helper
- [x] Create `lib/services/notes.js``listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
- [x] Create `lib/services/files.js``loadFolder`, `addFile`, `deleteFile`, etc.
- [x] Create `lib/services/search.js``searchNodes`, `getNodeDetail`, `searchWorkspace`
- [x] Create `lib/services/inbox.js` — inbox capture and management
- [x] Create `lib/services/trash.js` — trash operations
- [x] Create `lib/services/sync.js` — sync status and trigger
- [x] Create `lib/services/journal.js` — worklog CRUD and reports
- [x] Create `lib/services/actions.js` — actions CRUD
- [x] Create `lib/services/links.js` — links CRUD
- [x] Create `lib/services/activity.js` — activity feed
- [x] Create `lib/services/nodes.js` — tree, node CRUD, system views
- [x] Create `lib/services/suggestions.js` — worklog suggestions
- [x] Create `lib/services/today.js` — today dashboard
- [x] Create `lib/services/browserEvents.js` — browser extension events
- [x] Create `lib/services/search.js``searchNodes`
- [x] Create `lib/services/inbox.js``listInbox`, `captureClipboard`, etc.
- [x] Create `lib/services/trash.js``loadTrash`, `restore`, `purge`
- [x] Create `lib/services/sync.js``loadSyncStatus`, `runSync`
- [x] Create `lib/services/journal.js``loadJournal`, `worklog CRUD`
- [x] Create `lib/services/actions.js``listActions`, `createAction`, `deleteAction`
- [x] Create `lib/services/links.js``listLinks`, `updateLink`, `deleteLink`
- [x] Create `lib/services/activity.js``loadActivityFeed`, `loadCaseActivity`
## Phase 3: State Extraction
- [ ] Create `lib/state/navigation.js``selectedSection`, `selectedNode`, `activeTab`, `navHistory`
- [ ] Create `lib/state/uiState.js` — modals state, confirm state, rename state, drag state
## Phase 4: Component Extraction — Layout
- [ ] Extract `Sidebar.svelte` — brand, nav items, workspace tree, footer
- [ ] Extract `MainWorkspace.svelte` — content routing
- [ ] Create `AppShell.svelte` — root layout wrapper
## Phase 5: Component Extraction — Tab Content
- [x] Extract `OverviewTab.svelte`
- [x] Extract `NotesTab.svelte`
- [x] Extract `FilesTab.svelte`
- [ ] Extract `LinksTab.svelte`
- [ ] Extract `ActionsTab.svelte`
- [ ] Extract `WorklogTab.svelte`
- [ ] Extract `ActivityTabContent.svelte`
- [ ] Extract `InboxContent.svelte`
- [ ] Extract `InboxFullScreen.svelte`
- [ ] Extract `TrashContent.svelte`
- [ ] Extract `JournalScreen.svelte`
- [ ] Extract `ActivityFeedScreen.svelte`
- [ ] Extract `WelcomeScreen.svelte`
## Phase 6: Component Extraction — Modals
- [ ] Extract `CreateNodeModal.svelte`
- [ ] Extract `WorklogModal.svelte`
- [ ] Extract `CreateActionModal.svelte`
- [ ] Extract `ImportModal.svelte`
- [ ] Extract `RenameModal.svelte`
- [ ] Extract `AssignInboxModal.svelte`
- [ ] Extract `EditLinkModal.svelte`
- [ ] Extract `ContextMenu.svelte`
## Phase 7: Extract Inline Components
- [ ] Extract `NoteEditorHeader.svelte` (note editor header with rename)
- [ ] Extract `ErrorBanner.svelte`
- [ ] Extract `CaptureDropOverlay.svelte`
- [ ] Extract `SidebarFooter.svelte`
## Phase 8: Verification
- [ ] `npm run build` passes
- [ ] `go test ./...` passes
- [ ] Smoke checklist:
1. Sidebar renders with system views
2. Workspace tree loads and is expandable
3. Selecting a node shows tabs
4. Overview tab shows metadata and quick actions
5. Notes tab — create, rename, delete notes
6. Note editor — edit, preview, save, internal links, external links
7. Files tab — browse, add file/folder, navigate breadcrumbs
8. File preview — open, close
9. Inbox — list, sort, group, assign, delete
10. Trash — browse, restore, purge
11. Journal — filter, export, worklog CRUD
12. Activity feed — load and open events
13. Today screen — dashboard, suggestions, browser events
14. Settings — open/close, sections
15. Context menu on workspace tree
16. Create node modal — templates
+152 -219
View File
@@ -18,15 +18,10 @@
import { t } from './lib/i18n' import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte' import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte' import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
import ErrorBanner from './lib/components/ErrorBanner.svelte'
import CaptureDropOverlay from './lib/components/CaptureDropOverlay.svelte'
import OverviewTab from './lib/components/OverviewTab.svelte'
import NotesTab from './lib/components/notes/NotesTab.svelte' import NotesTab from './lib/components/notes/NotesTab.svelte'
import OverviewTab from './lib/components/OverviewTab.svelte'
import FilesTab from './lib/components/files/FilesTab.svelte' import FilesTab from './lib/components/files/FilesTab.svelte'
// Component refs
let filesTabRef = null
// ===== Wails v2 API call helper ===== // ===== Wails v2 API call helper =====
function wailsCall(method, ...args) { function wailsCall(method, ...args) {
try { try {
@@ -84,22 +79,10 @@
let selectedSection = '' let selectedSection = ''
let selectedNode = null let selectedNode = null
let activeTab = 'overview' let activeTab = 'overview'
// Trash preview state (kept in App.svelte because trash lives outside FilesTab)
let previewItem = null
let previewContent = ''
let previewLoading = false
let previewError = ''
// Capture/drag-drop overlay state (kept in App.svelte; used by inbox capture flow)
let captureDropActive = false
let captureDropLabel = ''
let captureDragDepth = 0
let lastCaptureDragOverAt = 0
let captureDragResetTimer = null
let dropRootValid = false
let inboxDropValid = false
let notes = [] let notes = []
let noteEditor = null let noteEditor = null
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
let filesTabRef = undefined; // bind:this ref for FilesTab
let noteViewMode = 'edit' let noteViewMode = 'edit'
let showLinkModal = false let showLinkModal = false
let linkModalLabel = '' let linkModalLabel = ''
@@ -163,8 +146,6 @@
let createInNode = null let createInNode = null
let createWithTemplate = null let createWithTemplate = null
let contextMenu = { visible: false, x: 0, y: 0, node: null } let contextMenu = { visible: false, x: 0, y: 0, node: null }
let showCreateNote = false
let newNoteTitle = ''
let showCreateAction = false let showCreateAction = false
let newActionTitle = '' let newActionTitle = ''
let newActionKind = 'open_url' let newActionKind = 'open_url'
@@ -179,6 +160,21 @@
{ id: 'launch_app', label: t('action.launchApp') }, { id: 'launch_app', label: t('action.launchApp') },
] ]
let loading = true let loading = true
let treeItems = []
let expanded = {}
let childrenMap = {}
let trashPreviewItem = null
let trashPreviewContent = ''
let trashPreviewLoading = false
let trashPreviewError = ''
let dropRootValid = false
let inboxDropValid = false
let captureDropActive = false
let captureDropLabel = ''
let captureDragDepth = 0
let lastCaptureDragOverAt = 0
let captureDragResetTimer = null
let showConfirm = false let showConfirm = false
let confirmTitle = '' let confirmTitle = ''
@@ -296,12 +292,12 @@
function closeTopModalForBack() { function closeTopModalForBack() {
if (showConfirm) { closeConfirm(); return true } if (showConfirm) { closeConfirm(); return true }
if (showSettings) { closeSettings(); return true } if (showSettings) { closeSettings(); return true }
if (trashPreviewItem) { closeTrashPreview(); return true }
if (assignInboxItem) { closeAssignInbox(); return true } if (assignInboxItem) { closeAssignInbox(); return true }
if (editingLink) { closeEditLink(); return true } if (editingLink) { closeEditLink(); return true }
if (showRename) { showRename = false; return true } if (showRename) { showRename = false; return true }
if (showWorklogModal) { closeWorklogModal(); return true } if (showWorklogModal) { closeWorklogModal(); return true }
if (showCreateAction) { cancelCreateAction(); return true } if (showCreateAction) { cancelCreateAction(); return true }
if (showCreateNote) { cancelCreateNote(); return true }
if (showCreateNode) { cancelCreateNode(); return true } if (showCreateNode) { cancelCreateNode(); return true }
if (contextMenu.visible) { closeContextMenu(); return true } if (contextMenu.visible) { closeContextMenu(); return true }
if (noteEditor) { closeNoteEditor(); return true } if (noteEditor) { closeNoteEditor(); return true }
@@ -318,6 +314,9 @@
} }
await selectNode(node) await selectNode(node)
activeTab = snapshot.tab || 'overview' activeTab = snapshot.tab || 'overview'
if (activeTab === 'files') {
filesTabRef?.resetToNode?.(node.id)
}
return true return true
} else if (snapshot.section) { } else if (snapshot.section) {
if (!systemViews.some(view => view.id === snapshot.section)) { if (!systemViews.some(view => view.id === snapshot.section)) {
@@ -362,6 +361,7 @@
if (activeTab === tabId) return if (activeTab === tabId) return
rememberNavigation() rememberNavigation()
activeTab = tabId activeTab = tabId
if (tabId === 'files' && selectedNode) filesTabRef?.resetToNode?.(selectedNode.id)
} }
// ===== Lifecycle ===== // ===== Lifecycle =====
@@ -512,10 +512,8 @@
resetTrashBrowser() resetTrashBrowser()
noteEditor = null noteEditor = null
showCreateNode = false showCreateNode = false
showCreateNote = false
error = '' error = ''
caseActivity = [] caseActivity = []
if (filesTabRef) filesTabRef.resetToNode(node.id)
await loadTabData(node.id) await loadTabData(node.id)
} }
@@ -533,7 +531,63 @@
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {} try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
} }
// ===== Keyboard ===== async function loadTree(nodeID) {
try {
treeItems = await wailsCall('ListItems', nodeID) || []
} catch (e) {
treeItems = []
}
}
// ===== Node rename (for tree context menu) =====
function openNodeRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
async function submitNodeRename() {
const name = renameValue.trim()
if (!name) { renameError = t('rename.emptyError'); return }
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = t('rename.invalidError')
return
}
showRename = false
const id = renameId
renameId = ''
try {
await wailsCall('RenameNode', id, name)
if (selectedNode && selectedNode.id === id) {
selectedNode = { ...selectedNode, title: name }
}
await reloadTreePreservingExpanded()
} catch (e) {
error = String(e)
}
}
function cancelNodeRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onNodeRenameKeydown(e) {
if (e.key === 'Enter') submitNodeRename()
else renameError = ''
}
// ===== Trash preview =====
function closeTrashPreview() {
trashPreviewItem = null
trashPreviewContent = ''
trashPreviewError = ''
}
function isEditableTarget(target) { function isEditableTarget(target) {
if (!target || !(target instanceof Element)) return false if (!target || !(target instanceof Element)) return false
@@ -554,70 +608,13 @@
return return
} }
if (activeTab !== 'files') return if (activeTab === 'files' && filesTabRef) {
return filesTabRef.handleFilesKeydown(e)
if (filesTabRef) {
filesTabRef.filesHandleKeydown(e)
return
} }
} }
// ===== Rename modal ===== // ===== Rename modal =====
function openRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
openRename(item.id, item.name)
}
}
}
async function submitRename() {
const name = renameValue.trim()
if (!name) { renameError = t('rename.emptyError'); return }
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = t('rename.invalidError')
return
}
showRename = false
const id = renameId
renameId = ''
try {
await wailsCall('RenameNode', id, name)
if (selectedNode && selectedNode.id === id) {
selectedNode = { ...selectedNode, title: name }
}
await reloadTreePreservingExpanded()
if (currentFolderId) {
await loadFolder(currentFolderId)
}
} catch (e) {
error = String(e)
}
}
function cancelRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onRenameKeydown(e) {
if (e.key === 'Enter') submitRename()
else renameError = ''
}
// ===== Confirm modal ===== // ===== Confirm modal =====
function openConfirm(opts) { function openConfirm(opts) {
@@ -770,7 +767,7 @@
// ===== Node operations from context menu ===== // ===== Node operations from context menu =====
function openRenameForNode(node) { function openRenameForNode(node) {
openRename(node.id, node.title) openNodeRename(node.id, node.title)
closeContextMenu() closeContextMenu()
} }
@@ -843,12 +840,8 @@
selectedSection = '' selectedSection = ''
selectedNode = item selectedNode = item
activeTab = 'files' activeTab = 'files'
folderStack = []
currentFolderId = null
selectedIds = []
previewItem = null
await loadTabData(item.id) await loadTabData(item.id)
await loadFolder(item.id) filesTabRef?.resetToNode?.(item.id)
return return
} }
@@ -858,7 +851,8 @@
if (!record) throw new Error('file record not found') if (!record) throw new Error('file record not found')
const preview = fileRecordToPreviewItem(item, record) const preview = fileRecordToPreviewItem(item, record)
if (canPreviewFile(preview)) { if (canPreviewFile(preview)) {
await openPreview(preview) activeTab = 'files'
filesTabRef?.openFileById?.(item.id)
} else { } else {
await wailsCall('OpenFile', preview.fileId) await wailsCall('OpenFile', preview.fileId)
} }
@@ -919,21 +913,17 @@
} }
// ===== Notes ===== // ===== Notes =====
function openCreateNote() { showCreateNote = true; newNoteTitle = '' } // Notes create/delete/rename are handled by NotesTab component via events.
function cancelCreateNote() { showCreateNote = false; newNoteTitle = '' } // App.svelte keeps note editor lifecycle (doOpenNote, saveCurrentNote, etc.)
async function submitCreateNote() {
if (!newNoteTitle.trim() || !selectedNode) return async function _handleSubmitCreateNote(title) {
if (!title.trim() || !selectedNode) return
try { try {
const note = await wailsCall('CreateNote', selectedNode.id, newNoteTitle.trim()) const note = await wailsCall('CreateNote', selectedNode.id, title.trim())
notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }] notes = [...notes, (note && note.id) ? note : { id: Date.now().toString(), title: title.trim(), createdAt: new Date().toISOString() }]
showCreateNote = false
newNoteTitle = ''
} catch (e) { } catch (e) {
// Fallback: create note locally const newNote = { id: Date.now().toString(), title: title.trim(), createdAt: new Date().toISOString() }
const newNote = { id: Date.now().toString(), title: newNoteTitle.trim(), createdAt: new Date().toISOString() }
notes = [...notes, newNote] notes = [...notes, newNote]
showCreateNote = false
newNoteTitle = ''
} }
} }
@@ -1167,14 +1157,8 @@
await selectNode(parent) await selectNode(parent)
} }
setActiveTab('files') setActiveTab('files')
await loadFolder(parentId) filesTabRef?.resetToNode?.(parentId)
// Find the file in the loaded fileItems and open preview filesTabRef?.openFileById?.(id)
const fileItem = fileItems.find(f => f.id === id)
if (fileItem) {
await openPreview(fileItem)
} else {
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
}
} else { } else {
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title })) showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
} }
@@ -1344,22 +1328,22 @@
} }
async function openTrashFilePreview(node) { async function openTrashFilePreview(node) {
previewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id } trashPreviewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
previewContent = '' trashPreviewContent = ''
previewError = '' trashPreviewError = ''
previewLoading = true trashPreviewLoading = true
try { try {
if (node.trashFsPath) { if (node.trashFsPath) {
previewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || '' trashPreviewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
} else { } else {
previewContent = await wailsCall('ReadTrashFileContent', node.id) || '' trashPreviewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
} }
const ext = (node.title || '').split('.').pop().toLowerCase() const ext = (node.title || '').split('.').pop().toLowerCase()
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) { if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent) trashPreviewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
} }
} catch (e) { previewError = String(e) } } catch (e) { trashPreviewError = String(e) }
previewLoading = false trashPreviewLoading = false
} }
function toggleTrashSelection(id) { function toggleTrashSelection(id) {
@@ -1638,15 +1622,7 @@
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
// ===== Drag-and-drop ===== // ===== Files =====
async function openSelectedFile(fileID) {
try {
await wailsCall('OpenFile', fileID)
} catch (e) {
error = String(e)
}
}
// ===== Drag-and-drop ===== // ===== Drag-and-drop =====
async function onFilesDropped(paths) { async function onFilesDropped(paths) {
try { try {
@@ -1928,7 +1904,6 @@
} }
} }
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') ||
@@ -2255,20 +2230,17 @@
try { try {
const detail = await wailsCall('GetNodeDetail', target.targetId) const detail = await wailsCall('GetNodeDetail', target.targetId)
if (detail && detail.parent_id) { if (detail && detail.parent_id) {
await loadFolder(detail.parent_id) filesTabRef?.resetToNode?.(detail.parent_id)
const fileItem = fileItems.find(f => f.id === target.targetId) filesTabRef?.openFileById?.(target.targetId)
if (fileItem && fileItem.type === 'file' && canPreviewFile(fileItem)) {
setTimeout(() => openPreview(fileItem), 150)
}
} else { } else {
// No parent — item sits at the root level // No parent — item sits at the root level
await loadFolder(targetNode) filesTabRef?.resetToNode?.(targetNode)
} }
} catch(e) { } catch(e) {
await loadFolder(targetNode) filesTabRef?.resetToNode?.(targetNode)
} }
} else { } else {
await loadFolder(targetNode) filesTabRef?.resetToNode?.(targetNode)
} }
} }
} catch (e) { } catch (e) {
@@ -2319,18 +2291,15 @@
if (parent) { if (parent) {
await selectNode(parent) await selectNode(parent)
setActiveTab('files') setActiveTab('files')
await loadFolder(parent.id) filesTabRef?.resetToNode?.(parent.id)
const fileItem = fileItems.find(item => item.id === detail.id) filesTabRef?.openFileById?.(detail.id)
if (fileItem && canPreviewFile(fileItem)) {
await openPreview(fileItem)
}
} }
return return
} }
if (result.type === 'folder') { if (result.type === 'folder') {
await selectNode(detail) await selectNode(detail)
setActiveTab('files') setActiveTab('files')
await loadFolder(detail.id) filesTabRef?.resetToNode?.(detail.id)
return return
} }
await selectNode(detail) await selectNode(detail)
@@ -2425,13 +2394,19 @@
} }
</script> </script>
{#if showFirstRun} {#if showFirstRun}
<FirstRun onComplete={onFirstRunComplete} /> <FirstRun onComplete={onFirstRunComplete} />
{:else if showRecovery} {:else if showRecovery}
<VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} /> <VaultRecovery vaultPath={startupStatus?.vaultPath || ''} onComplete={onRecoveryComplete} />
{:else} {:else}
<div class="app"> <div class="app">
<CaptureDropOverlay show={captureDropActive} label={captureDropLabel} /> {#if captureDropActive}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{captureDropLabel}</div>
</div>
{/if}
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
@@ -2522,7 +2497,16 @@
</div> </div>
</AppHeader> </AppHeader>
<ErrorBanner {error} onDismiss={() => error = ''} /> {#if error}
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => 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"/>
</svg>
</button>
</div>
{/if}
{#if noteEditor} {#if noteEditor}
<!-- Note editor with markdown preview --> <!-- Note editor with markdown preview -->
@@ -2578,40 +2562,35 @@
<div class="tab-content"> <div class="tab-content">
{#if activeTab === 'overview'} {#if activeTab === 'overview'}
<OverviewTab <OverviewTab
node={selectedNode} {selectedNode}
{notes} {notes}
{worklog} {worklog}
{nodeKindLabel}
{formatDate} {formatDate}
on:goTab={(e) => setActiveTab(e.detail)} {nodeKindLabel}
on:openNote={(e) => openNote(e.detail.note)} on:createNote={() => { setActiveTab('notes'); }}
on:createNote={() => { setActiveTab('notes'); openCreateNote() }} on:addFile={() => { setActiveTab('files'); if (filesTabRef) filesTabRef.addFile(); }}
on:addFile={() => { setActiveTab('files'); addFile() }}
on:createAction={openCreateAction} on:createAction={openCreateAction}
on:openNote={(e) => openNote(e.detail.note)}
/> />
{:else if activeTab === 'notes'} {:else if activeTab === 'notes'}
<NotesTab <NotesTab
{notes} {notes}
showCreateNote={showCreateNote}
{formatDate} {formatDate}
on:createNote={openCreateNote} on:submitCreateNote={(e) => _handleSubmitCreateNote(e.detail.title)}
on:submitCreateNote={(e) => { newNoteTitle = e.detail.title; submitCreateNote() }}
on:cancelCreateNote={cancelCreateNote}
on:openNote={(e) => openNote(e.detail.note)} on:openNote={(e) => openNote(e.detail.note)}
on:startRename={(e) => startRenameNote(e.detail.note.id, e.detail.note.title)} on:startRename={(e) => startRenameNote(e.detail.noteId, e.detail.currentTitle)}
on:delete={(e) => deleteNote(e.detail.note)} on:deleteNote={(e) => deleteNote(e.detail.note)}
/> />
{:else if activeTab === 'files'} {:else if activeTab === 'files'}
<FilesTab <FilesTab
bind:this={filesTabRef} bind:this={filesTabRef}
{selectedNode} {selectedNode}
on:openNote={(e) => openNote(e.detail.note)} {wailsCall}
on:openNote={(e) => openNote(e.detail)}
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)} on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
on:error={(e) => { error = e.detail.message }} on:error={(e) => error = e.detail.message}
on:rename={(e) => openRename(e.detail.id, e.detail.name)}
on:confirm={(e) => openConfirm(e.detail)}
/> />
{:else if activeTab === 'inbox'} {:else if activeTab === 'inbox'}
@@ -3479,21 +3458,21 @@
{/if} {/if}
{#if showRename} {#if showRename}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}> <div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelNodeRename} on:keydown={onKeyActivate(cancelNodeRename)}>
<div class="modal"> <div class="modal">
<h3>{t('rename.title')}</h3> <h3>{t('rename.title')}</h3>
<div class="form-group"> <div class="form-group">
<label><span class="label-text">{t('common.newName')}</span> <label><span class="label-text">{t('common.newName')}</span>
<input type="text" bind:value={renameValue} <input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} /> on:keydown={onNodeRenameKeydown} />
</label> </label>
</div> </div>
{#if renameError} {#if renameError}
<div class="rename-error">{renameError}</div> <div class="rename-error">{renameError}</div>
{/if} {/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button> <button class="btn btn-primary" on:click={submitNodeRename}>{t('common.rename')}</button>
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button> <button class="btn" on:click={cancelNodeRename}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -3574,13 +3553,13 @@
/> />
{/if} {/if}
{#if previewItem} {#if trashPreviewItem}
<FilePreviewModal <FilePreviewModal
item={previewItem} item={trashPreviewItem}
content={previewContent} content={trashPreviewContent}
loading={previewLoading} loading={trashPreviewLoading}
error={previewError} error={trashPreviewError}
on:close={closePreview} on:close={closeTrashPreview}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)} on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
/> />
{/if} {/if}
@@ -3592,6 +3571,7 @@
</div> </div>
{/if} {/if}
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; } .app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
@@ -3666,15 +3646,6 @@
.note-rename-input { flex: 1; padding: 6px 10px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; } .note-rename-input { flex: 1; padding: 6px 10px; border: 1px solid #2a2a3c; border-radius: 4px; background: #13131f; color: #e4e4ef; font-size: 14px; font-family: inherit; outline: none; }
.note-rename-input:focus { border-color: #818cf8; } .note-rename-input:focus { border-color: #818cf8; }
/* Note card actions */
.note-card { position: relative; }
.note-card-info { flex: 1; min-width: 0; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
.note-card:hover .note-card-actions { opacity: 1; }
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
.note-action-danger:hover { background: rgba(239, 68, 68, 0.15); color: #f87171; }
/* Form groups in modals */ /* Form groups in modals */
.form-group { margin-bottom: 14px; } .form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; } .form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; }
@@ -3682,36 +3653,6 @@
.form-group input:focus { border-color: #818cf8; } .form-group input:focus { border-color: #818cf8; }
.note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; } .note-textarea { flex: 1; width: 100%; border: none; outline: none; background: #13131f; color: #e4e4ef; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; padding: 24px; resize: none; }
/* Overview */
.overview { padding: 24px; }
.overview h2 { font-size: 24px; margin-bottom: 16px; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.qa-btn:hover { background: #222233; }
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.recent-section { margin-bottom: 24px; }
.recent-section h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
.recent-note { padding: 8px 12px; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; }
.recent-note:hover { background: #1a1a28; }
.recent-date { font-size: 11px; color: #555; }
.recent-entry { padding: 6px 0; font-size: 13px; color: #888; border-bottom: 1px solid #1a1a28; }
/* Notes tab */
.notes-tab { padding: 24px; }
.tab-toolbar { margin-bottom: 16px; }
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
.create-form input:focus { outline: none; border-color: #6366f1; }
.form-actions { display: flex; gap: 8px; }
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; }
.note-card:hover { border-color: #3a3a5c; }
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.note-card-date { font-size: 11px; color: #555; }
/* Worklog tab */ /* Worklog tab */
.worklog-tab { padding: 24px; } .worklog-tab { padding: 24px; }
.worklog-toolbar { margin-bottom: 16px; } .worklog-toolbar { margin-bottom: 16px; }
@@ -3977,17 +3918,9 @@
} }
/* Files tab */ /* Files tab */
.files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.file-list { display: flex; flex-direction: column; }
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
/* Import summary */ /* Import summary */
.import-summary { margin-bottom: 16px; }
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
/* Template cards */ /* Template cards */
.template-cards { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } .template-cards { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
+9 -1
View File
@@ -88,7 +88,14 @@
} }
} }
function toggleMenu() { function toggleMenu(e) {
if (e) {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
menuX = Math.min(rect.right, window.innerWidth - 240)
menuY = Math.min(rect.bottom, window.innerHeight - 320)
console.log('[FileTreeRow] menu source=button x=' + menuX + ' y=' + menuY)
}
menuOpen = !menuOpen menuOpen = !menuOpen
} }
@@ -112,6 +119,7 @@
e.preventDefault() e.preventDefault()
menuX = Math.min(e.clientX, window.innerWidth - 240) menuX = Math.min(e.clientX, window.innerWidth - 240)
menuY = Math.min(e.clientY, window.innerHeight - 320) menuY = Math.min(e.clientY, window.innerHeight - 320)
console.log('[FileTreeRow] menu source=contextmenu x=' + menuX + ' y=' + menuY)
menuOpen = true menuOpen = true
} }
+27 -9
View File
@@ -1,6 +1,7 @@
<script> <script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte' import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte' import FileIcon from './FileIcon.svelte'
import MarkdownPreview from './components/notes/MarkdownPreview.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js' import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
import { t } from './i18n' import { t } from './i18n'
@@ -13,7 +14,8 @@
const kind = getFileKind(item) const kind = getFileKind(item)
$: showImage = isImageFile(item) && content && content.startsWith('data:') $: showImage = isImageFile(item) && content && content.startsWith('data:')
$: showText = isTextFile(item) || isMarkdownFile(item) $: showMarkdown = isMarkdownFile(item) && content
$: showText = (isTextFile(item) || isMarkdownFile(item)) && content && !showMarkdown
$: showPdf = isPdfFile(item) $: showPdf = isPdfFile(item)
function handleKeydown(e) { function handleKeydown(e) {
@@ -44,13 +46,6 @@
</div> </div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div> <div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions"> <div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview"> <button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" 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="18" y1="6" x2="6" y2="18"/>
@@ -71,7 +66,11 @@
<div class="preview-image-container"> <div class="preview-image-container">
<img src={content} alt={item.name} class="preview-image"/> <img src={content} alt={item.name} class="preview-image"/>
</div> </div>
{:else if showText && content} {:else if showMarkdown}
<div class="preview-markdown-container">
<MarkdownPreview {content} />
</div>
{:else if showText}
<pre class="preview-text"><code>{content}</code></pre> <pre class="preview-text"><code>{content}</code></pre>
{:else if showPdf} {:else if showPdf}
{#if content && content.startsWith('data:')} {#if content && content.startsWith('data:')}
@@ -91,6 +90,9 @@
</div> </div>
{/if} {/if}
</div> </div>
<footer class="preview-footer">
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</footer>
</div> </div>
</div> </div>
@@ -260,4 +262,20 @@
.btn-sm:hover { .btn-sm:hover {
background: #222233; background: #222233;
} }
.preview-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.preview-markdown-container {
flex: 1;
overflow: auto;
min-height: 0;
padding: 16px;
}
</style> </style>
@@ -1,15 +0,0 @@
<script>
export let show = false
export let label = ''
</script>
{#if show}
<div class="capture-drop-overlay">
<div class="capture-drop-box">{label}</div>
</div>
{/if}
<style>
.capture-drop-overlay { position: fixed; inset: 0; z-index: 120; pointer-events: none; display: flex; align-items: center; justify-content: center; background: rgba(19, 19, 31, 0.42); border: 2px dashed #818cf8; }
.capture-drop-box { max-width: min(520px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid #3a3a5c; border-radius: 8px; background: #1a1a28; color: #e4e4ef; font-size: 14px; font-weight: 600; box-shadow: 0 12px 32px rgba(0,0,0,0.35); text-align: center; }
</style>
@@ -1,37 +0,0 @@
<script>
import { t } from '../i18n'
export let error = ''
export let onDismiss = () => {}
function translateError(msg) {
const map = {
'vault not open': t('error.vaultNotOpen'),
}
return map[msg] || msg
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onDismiss()
}
}
</script>
{#if error}
<div class="error-banner" role="button" tabindex="0" on:click={onDismiss} on:keydown={handleKeydown}>
{translateError(error)}
<button class="dismiss-btn" on:click|stopPropagation={onDismiss} 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"/>
</svg>
</button>
</div>
{/if}
<style>
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
.dismiss-btn:hover { color: #ff4444; }
</style>
+28 -29
View File
@@ -1,74 +1,73 @@
<script> <script>
import { t } from '../i18n'
import { actionIcon } from '../actionIcons'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { t } from '../i18n'
const dispatch = createEventDispatcher() // ===== Props =====
export let selectedNode = null
export let node = null
export let notes = [] export let notes = []
export let worklog = [] export let worklog = []
export let nodeKindLabel = (type) => type || '' export let formatDate = (str) => ''
export let formatDate = (d) => d || '' export let nodeKindLabel = (kind) => kind || ''
function goNotesCreate() { // ===== Events =====
dispatch('goTab', 'notes') const dispatch = createEventDispatcher()
function createNote() {
dispatch('createNote') dispatch('createNote')
} }
function goFilesAdd() { function addFile() {
dispatch('goTab', 'files')
dispatch('addFile') dispatch('addFile')
} }
function onKeyActivate(fn) { function createAction() {
return (e) => { dispatch('createAction')
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
} }
function logTime() {
dispatch('switchTab', { tab: 'worklog' })
} }
function openNoteHandler(note) {
dispatch('openNote', { note })
} }
</script> </script>
<div class="overview"> <div class="overview">
<h2 class="node-title">{node.title}</h2> <h2>{selectedNode.title}</h2>
<div class="meta-grid"> <div class="meta-grid">
<div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(node.type)}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{nodeKindLabel(selectedNode.type)}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{node.section || '—'}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(node.createdAt)}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
<button class="qa-btn" on:click={goNotesCreate}> <button class="qa-btn" on:click={createNote}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{t('overview.newNote')} {t('overview.newNote')}
</button> </button>
<button class="qa-btn" on:click={goFilesAdd}> <button class="qa-btn" on:click={addFile}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{t('overview.addFile')} {t('overview.addFile')}
</button> </button>
<button class="qa-btn" on:click={() => dispatch('createAction')}> <button class="qa-btn" on:click={createAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{t('overview.addAction')} {t('overview.addAction')}
</button> </button>
<button class="qa-btn" on:click={() => dispatch('goTab', 'worklog')}> <button class="qa-btn" on:click={logTime}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('overview.logTime')} {t('overview.logTime')}
</button> </button>
</div> </div>
{#if notes.length > 0} {#if notes.length > 0}
<div class="recent-section"> <div class="recent-section">
<h3>{t('overview.recentNotes')}</h3> <h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as note} {#each notes.slice(0, 5) as note}
<div class="recent-note" role="button" tabindex="0" on:click={() => dispatch('openNote', { note })} on:keydown={onKeyActivate(() => dispatch('openNote', { note }))}> <div class="recent-note" role="button" tabindex="0" on:click={() => openNoteHandler(note)} on:keydown={(e) => e.key === 'Enter' && openNoteHandler(note)}>
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span> <span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if worklog.length > 0} {#if worklog.length > 0}
<div class="recent-section"> <div class="recent-section">
<h3>{t('overview.recentEntries')}</h3> <h3>{t('overview.recentEntries')}</h3>
@@ -81,7 +80,7 @@
<style> <style>
.overview { padding: 24px; } .overview { padding: 24px; }
.node-title { font-size: 24px; margin-bottom: 16px; color: #e4e4ef; } .overview h2 { font-size: 24px; margin-bottom: 16px; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; } .meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; } .meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; } .meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
+433 -192
View File
@@ -1,25 +1,45 @@
<script> <script>
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { t } from '../../i18n'
import { isMarkdownFile } from '../../fileUtils.js'
import FileTreeRow from '../../../FileTreeRow.svelte'
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte' import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
import FileTreeRow from '../../../FileTreeRow.svelte'
import FilePreviewModal from '../../FilePreviewModal.svelte' import FilePreviewModal from '../../FilePreviewModal.svelte'
import ConfirmModal from '../../ConfirmModal.svelte'
import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n'
import EditorPanel from '../notes/EditorPanel.svelte'
// ===== Props =====
export let selectedNode = null
export let wailsCall = async () => {}
// ===== Debug helper =====
function _fdl(msg) {
try { wailsCall('WriteDebugLog', '[FilesTab] ' + msg) } catch(e) {}
}
// ===== Events =====
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
// Props // ===== Internal state (owned by FilesTab) =====
export let selectedNode = null
// Internal state
let loadingFiles = false let loadingFiles = false
let currentFolderId = null let currentFolderId = null
let folderStack = [] let folderStack = []
let fileItems = [] let fileItems = []
// Preview modal state (images, PDF, binary)
let previewItem = null let previewItem = null
let previewContent = '' let previewContent = ''
let previewLoading = false let previewLoading = false
let previewError = '' let previewError = ''
// Text file editor state (text, markdown, code)
let textEditorFile = null
let textEditorContent = ''
let textEditorLoading = false
let textEditorError = ''
let textEditorIsMarkdown = false
let textEditorMode = 'edit' // 'edit' | 'preview' | 'split'
let textEditorDirty = false
let textEditorIsNote = false
let textEditorNoteId = ''
let clipboard = { items: [], mode: 'copy' } let clipboard = { items: [], mode: 'copy' }
let selectedIds = [] let selectedIds = []
let dragIds = [] let dragIds = []
@@ -28,57 +48,71 @@
let showImportDialog = false let showImportDialog = false
let pendingImportPath = '' let pendingImportPath = ''
let pendingImportParent = '' let pendingImportParent = ''
let showRename = false
let renameId = ''
let renameValue = ''
let renameError = ''
let showConfirm = false
let confirmTitle = ''
let confirmMessage = ''
let confirmDanger = false
let confirmText = t('common.delete')
let confirmAction = null
let cancelAction = null
// ===== Wails call helper ===== // ===== Public API (called via bind:this) =====
function wailsCall(method, ...args) { export function resetToNode(nodeId) {
try { folderStack = []
if (window['go'] && window['go']['main'] && window['go']['main']['App']) { currentFolderId = null
const fn = window['go']['main']['App'][method] selectedIds = []
if (typeof fn === 'function') { dragIds = []
return fn(...args) previewItem = null
} previewContent = ''
} closeTextEditor()
} catch (e) { loadFolder(nodeId)
console.error('Wails call error:', method, e)
}
return Promise.reject(new Error('Wails not connected: ' + method))
} }
// ===== Public API for keyboard handler ===== // ===== React to selectedNode changes =====
export function filesHandleKeydown(e) { let lastLoadedNodeId = null
if (e.ctrlKey || e.metaKey) { $: if (selectedNode && selectedNode.id && selectedNode.id !== lastLoadedNodeId) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() } _fdl('selectedNode changed: id=' + selectedNode.id + ' title=' + selectedNode.title + ' type=' + selectedNode.type)
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() } lastLoadedNodeId = selectedNode.id
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() } resetToNode(selectedNode.id)
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete') {
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
} }
export function addFile() {
_addFile()
}
export function loadFolder(folderId) {
_loadFolder(folderId)
}
export function openFileById(fileNodeId) {
// Find the file in the current fileItems and open preview
const fileItem = fileItems.find(f => f.id === fileNodeId)
if (fileItem) {
_openFile(fileItem)
}
}
export function focusItem(nodeId) {
selectedIds = [nodeId]
} }
// ===== Folder navigation ===== // ===== Folder navigation =====
export async function loadFolder(folderId) { async function _loadFolder(folderId) {
loadingFiles = true loadingFiles = true
try { try {
let items = await wailsCall('ListItems', folderId) || [] let items = await wailsCall('ListItems', folderId) || []
_fdl('loadFolder nodeId=' + folderId + ' count=' + items.length + ' first=' + (items[0] ? JSON.stringify({id: items[0].id, name: items[0].name, type: items[0].type, fileId: items[0].fileId}) : 'none'))
items.sort((a, b) => { items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return (a.name || '').localeCompare(b.name || '') return (a.name || '').localeCompare(b.name || '')
}) })
fileItems = items fileItems = items
} catch (e) { } catch (e) {
_fdl('loadFolder ERROR nodeId=' + folderId + ' err=' + String(e))
fileItems = [] fileItems = []
} }
loadingFiles = false loadingFiles = false
@@ -95,19 +129,19 @@
folderStack = [...folderStack, { id: folderId, name: '...' }] folderStack = [...folderStack, { id: folderId, name: '...' }]
} }
currentFolderId = folderId currentFolderId = folderId
await loadFolder(folderId) await _loadFolder(folderId)
} }
function navigateBack() { function navigateBack() {
if (folderStack.length < 2) { if (folderStack.length < 2) {
folderStack = [] folderStack = []
currentFolderId = null currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id) if (selectedNode) _loadFolder(selectedNode.id)
} else { } else {
const target = folderStack[folderStack.length - 2] const target = folderStack[folderStack.length - 2]
folderStack = folderStack.slice(0, -1) folderStack = folderStack.slice(0, -1)
currentFolderId = target.id currentFolderId = target.id
loadFolder(target.id) _loadFolder(target.id)
} }
} }
@@ -115,37 +149,94 @@
const target = folderStack[index] const target = folderStack[index]
folderStack = folderStack.slice(0, index + 1) folderStack = folderStack.slice(0, index + 1)
currentFolderId = target.id currentFolderId = target.id
loadFolder(target.id) _loadFolder(target.id)
} }
// ===== File preview ===== // ===== Open file: text editor or preview modal =====
async function openPreview(item) { async function _openFile(item) {
_fdl('openFile item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
// 1. Markdown: check if linked to a note
if (item && item.fileId && isMarkdownFile(item)) { if (item && item.fileId && isMarkdownFile(item)) {
try { try {
const action = await wailsCall('CheckFileAction', item.fileId) const action = await wailsCall('CheckFileAction', item.fileId)
if (action.action === 'note') { _fdl('CheckFileAction result=' + JSON.stringify(action))
dispatch('openNote', { note: { id: action.noteId, title: action.noteTitle } }) if (action && action.action === 'note') {
return // Linked note → open note editor
} dispatch('openNote', { id: action.noteId, title: action.noteTitle })
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return return
} }
// action === 'preview' → fall through to text editor below
} catch (e) { } catch (e) {
console.warn('CheckFileAction failed, falling back to preview:', e) _fdl('CheckFileAction ERROR: ' + String(e))
} }
} }
// 2. Text/markdown files → TextFileEditor
if (item && item.fileId && (isTextFile(item) || isMarkdownFile(item))) {
_fdl('opening text editor for ' + item.name)
openTextEditor(item, isMarkdownFile(item))
return
}
// 3. Images / PDF / binary preview → FilePreviewModal
if (item && item.fileId && canPreviewFile(item)) {
_fdl('opening preview modal for ' + item.name)
openPreviewModal(item)
return
}
// 4. Fallback: unknown type, try as text
if (item && item.fileId && item.type !== 'folder') {
_fdl('fallback: trying text editor for unknown type ' + item.name)
openTextEditor(item, false)
return
}
_fdl('no suitable opener for ' + item.name)
}
async function openTextEditor(item, isMarkdown, noteId) {
textEditorFile = item
textEditorContent = ''
textEditorError = ''
textEditorLoading = true
textEditorIsMarkdown = isMarkdown
textEditorMode = 'edit'
textEditorDirty = false
textEditorIsNote = !!noteId
textEditorNoteId = noteId || ''
try {
textEditorContent = await wailsCall('ReadFileText', item.fileId) || ''
} catch (e) {
textEditorError = String(e)
}
textEditorLoading = false
}
function closeTextEditor() {
textEditorFile = null
textEditorContent = ''
textEditorError = ''
textEditorIsMarkdown = false
textEditorMode = 'view'
textEditorDirty = false
}
function saveTextEditor() {
if (!textEditorFile || !textEditorDirty) return
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
textEditorDirty = false
}
async function openPreviewModal(item) {
previewItem = item previewItem = item
previewContent = '' previewContent = ''
previewError = '' previewError = ''
previewLoading = true previewLoading = true
try { try {
if (needsBase64Preview(item)) { if (needsBase64Preview(item)) {
previewContent = await wailsCall('GetFileBase64', item.fileId) || '' previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
} }
} catch (e) { } catch (e) {
previewError = String(e) previewError = String(e)
@@ -159,32 +250,28 @@
previewError = '' previewError = ''
} }
// ===== Helpers for preview =====
function needsBase64Preview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.png') || ext.endsWith('.jpg') || ext.endsWith('.jpeg') ||
ext.endsWith('.gif') || ext.endsWith('.webp') || ext.endsWith('.svg') ||
ext.endsWith('.bmp') || ext.endsWith('.ico') || ext.endsWith('.pdf')
}
function needsTextPreview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.txt') || ext.endsWith('.log') || ext.endsWith('.json') ||
ext.endsWith('.xml') || ext.endsWith('.csv') || ext.endsWith('.md') ||
ext.endsWith('.js') || ext.endsWith('.ts') || ext.endsWith('.css') ||
ext.endsWith('.html') || ext.endsWith('.yaml') || ext.endsWith('.yml')
}
// ===== File operations ===== // ===== File operations =====
async function _addFile() {
const path = await wailsCall('PickFile')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await _startImport(parentId, path)
}
async function _addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await _startImport(parentId, path)
}
async function createFile() { async function createFile() {
const name = prompt(t('file.namePrompt')) const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return if (!name || !name.trim()) return
try { try {
const parentId = currentFolderId || selectedNode.id const parentId = currentFolderId || selectedNode.id
await wailsCall('CreateEmptyFile', parentId, name.trim()) await wailsCall('CreateEmptyFile', parentId, name.trim())
await loadFolder(parentId) await _loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId }) dispatch('refreshParent', { nodeId: parentId })
} catch (e) { } catch (e) {
dispatch('error', { message: String(e) }) dispatch('error', { message: String(e) })
@@ -195,7 +282,7 @@
try { try {
await wailsCall('DuplicateNode', id) await wailsCall('DuplicateNode', id)
const parentId = currentFolderId || selectedNode.id const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId) await _loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId }) dispatch('refreshParent', { nodeId: parentId })
} catch (e) { } catch (e) {
dispatch('error', { message: String(e) }) dispatch('error', { message: String(e) })
@@ -204,9 +291,7 @@
function renameItem(id) { function renameItem(id) {
const item = fileItems.find(x => x.id === id) const item = fileItems.find(x => x.id === id)
if (item) { if (item) _openRename(item.id, item.name)
dispatch('rename', { id, name: item.name })
}
} }
function cutItem(id) { function cutItem(id) {
@@ -231,17 +316,13 @@
} }
} }
clipboard = { items: [], mode: 'copy' } clipboard = { items: [], mode: 'copy' }
await loadFolder(targetId) await _loadFolder(targetId)
} catch (e) { } catch (e) {
dispatch('error', { message: String(e) }) dispatch('error', { message: String(e) })
} }
} }
// ===== Selection ===== // ===== Selection =====
function getTargetIds(ids) {
return ids.length > 0 ? ids : fileItems.map(x => x.id)
}
function toggleSelection(id) { function toggleSelection(id) {
if (selectedIds.includes(id)) { if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(x => x !== id) selectedIds = selectedIds.filter(x => x !== id)
@@ -258,10 +339,6 @@
selectedIds = fileItems.map(x => x.id) selectedIds = fileItems.map(x => x.id)
} }
function clearSelection() {
selectedIds = []
}
function rangeSelect(id) { function rangeSelect(id) {
if (fileItems.length === 0) return if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
@@ -276,47 +353,12 @@
selectedIds = [...set] selectedIds = [...set]
} }
function cutSelected() { function clearSelection() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = [] selectedIds = []
} }
function copySelected() { function getTargetIds(ids) {
const ids = getTargetIds(selectedIds) return ids.length > 0 ? ids : fileItems.map(x => x.id)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
dispatch('rename', { id: item.id, name: item.name })
}
}
} }
async function deleteSelected() { async function deleteSelected() {
@@ -330,7 +372,7 @@
} else { } else {
label = t('delete.files', { count: ids.length }) label = t('delete.files', { count: ids.length })
} }
dispatch('confirm', { _openConfirm({
title: t('delete.confirmTitle'), title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?', message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'), confirmText: t('common.delete'),
@@ -345,11 +387,23 @@
} }
selectedIds = [] selectedIds = []
const reloadId = currentFolderId || selectedNode.id const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId) await _loadFolder(reloadId)
} }
}) })
} }
function cutSelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = []
}
function copySelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
// ===== Drag-and-drop ===== // ===== Drag-and-drop =====
function onDragStart(e, id) { function onDragStart(e, id) {
e.stopPropagation() e.stopPropagation()
@@ -381,25 +435,116 @@
} }
dragIds = [] dragIds = []
selectedIds = [] selectedIds = []
await loadFolder(currentFolderId || selectedNode.id) await _loadFolder(currentFolderId || selectedNode.id)
}
// ===== Keyboard =====
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
_openFile(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
_openRename(item.id, item.name)
}
}
}
// ===== Rename modal =====
function _openRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
async function submitRename() {
const name = renameValue.trim()
if (!name) { renameError = t('rename.emptyError'); return }
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = t('rename.invalidError')
return
}
showRename = false
const id = renameId
renameId = ''
try {
await wailsCall('RenameNode', id, name)
if (selectedNode && selectedNode.id === id) {
selectedNode = { ...selectedNode, title: name }
}
dispatch('refreshParent', { nodeId: currentFolderId || selectedNode.id })
if (currentFolderId) {
await _loadFolder(currentFolderId)
}
} catch (e) {
dispatch('error', { message: String(e) })
}
}
function cancelRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onRenameKeydown(e) {
if (e.key === 'Enter') submitRename()
else renameError = ''
}
// ===== Confirm modal =====
function _openConfirm(opts) {
confirmTitle = opts.title || t('common.confirm')
confirmMessage = opts.message || ''
confirmDanger = opts.danger !== undefined ? opts.danger : true
confirmText = opts.confirmText || t('common.delete')
confirmAction = opts.onConfirm || null
cancelAction = opts.onCancel || null
showConfirm = true
}
function closeConfirm() {
showConfirm = false
confirmAction = null
cancelAction = null
}
function handleConfirm() {
if (confirmAction) confirmAction()
closeConfirm()
}
function handleCancel() {
if (cancelAction) cancelAction()
closeConfirm()
} }
// ===== Import ===== // ===== Import =====
async function addFile() { async function _startImport(parentID, sourcePath) {
const path = await wailsCall('PickFile')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function startImport(parentID, sourcePath) {
importing = true importing = true
try { try {
const summary = await wailsCall('PreviewImport', sourcePath) const summary = await wailsCall('PreviewImport', sourcePath)
@@ -416,14 +561,16 @@
async function confirmImport(mode) { async function confirmImport(mode) {
try { try {
const parentId = pendingImportParent || selectedNode.id const parentId = pendingImportParent || selectedNode.id
const result = mode === 'copy' if (mode === 'copy') {
? await wailsCall('AddPathCopy', parentId, pendingImportPath) await wailsCall('AddPathCopy', parentId, pendingImportPath)
: await wailsCall('AddPathLink', parentId, pendingImportPath) } else {
await wailsCall('AddPathLink', parentId, pendingImportPath)
}
showImportDialog = false showImportDialog = false
importSummary = null importSummary = null
folderStack = [] folderStack = []
currentFolderId = null currentFolderId = null
await loadFolder(parentId) await _loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId }) dispatch('refreshParent', { nodeId: parentId })
} catch (e) { } catch (e) {
dispatch('error', { message: String(e) }) dispatch('error', { message: String(e) })
@@ -437,7 +584,7 @@
async function deleteFile({ id, type }) { async function deleteFile({ id, type }) {
const label = type === 'folder' ? t('delete.folder') : t('delete.file') const label = type === 'folder' ? t('delete.folder') : t('delete.file')
dispatch('confirm', { _openConfirm({
title: t('delete.confirmTitle'), title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?', message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'), confirmText: t('common.delete'),
@@ -446,7 +593,7 @@
try { try {
await wailsCall('DeleteFileOrFolder', id) await wailsCall('DeleteFileOrFolder', id)
const reloadId = currentFolderId || selectedNode.id const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId) await _loadFolder(reloadId)
} catch (e) { } catch (e) {
dispatch('error', { message: String(e) }) dispatch('error', { message: String(e) })
} }
@@ -454,34 +601,89 @@
}) })
} }
// ===== Public API: called from parent when node changes ===== async function openSelectedFile(fileID) {
export function resetToNode(nodeId) { try {
await wailsCall('OpenFile', fileID)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
// ===== Keyboard handler (called from parent) =====
export function handleFilesKeydown(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete') {
if (textEditorFile) { e.preventDefault(); closeTextEditor(); return }
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
} else if (e.key === 'Escape') {
if (textEditorFile) { closeTextEditor(); return }
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
// ===== Reset on node change =====
export function resetState() {
folderStack = [] folderStack = []
currentFolderId = null currentFolderId = null
fileItems = []
selectedIds = [] selectedIds = []
dragIds = [] dragIds = []
clipboard = { items: [], mode: 'copy' }
fileItems = []
loadingFiles = false
previewItem = null previewItem = null
previewContent = '' previewContent = ''
previewError = '' previewError = ''
clipboard = { items: [], mode: 'copy' }
importing = false importing = false
showImportDialog = false
importSummary = null importSummary = null
if (nodeId) loadFolder(nodeId) showImportDialog = false
} showRename = false
showConfirm = false
// Load folder when selectedNode changes
$: if (selectedNode && selectedNode.id && !currentFolderId && fileItems.length === 0 && !loadingFiles) {
loadFolder(selectedNode.id)
} }
</script> </script>
<!-- Files tab template -->
<div class="files-tab"> <div class="files-tab">
{#if textEditorFile}
<!-- ===== Editor mode: reuse the same EditorPanel as note editor ===== -->
<EditorPanel
content={textEditorContent}
title={textEditorFile.name}
isMarkdown={textEditorIsMarkdown}
loading={textEditorLoading}
error={textEditorError}
dirty={textEditorDirty}
viewMode={textEditorMode}
on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }}
on:save={() => {
if (textEditorIsNote) {
wailsCall('SaveNote', textEditorNoteId, textEditorContent)
} else {
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
}
textEditorDirty = false
}}
on:close={closeTextEditor}
on:openExternal={() => wailsCall('OpenFile', textEditorFile.fileId)}
on:mode-change={(e) => textEditorMode = e.detail.mode}
/>
{:else}
<!-- ===== Browser mode ===== -->
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button> <button class="btn btn-primary" on:click={_addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button> <button class="btn" on:click={_addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button> <button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0} {#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button> <button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
@@ -499,7 +701,7 @@
if (i === 0) { if (i === 0) {
folderStack = [] folderStack = []
currentFolderId = null currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id) if (selectedNode) _loadFolder(selectedNode.id)
} else { } else {
navigateToBreadcrumb(i - 1) navigateToBreadcrumb(i - 1)
} }
@@ -523,8 +725,8 @@
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p> <p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p> <p class="hint">{t('file.hint')}</p>
<div class="empty-actions"> <div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button> <button class="btn btn-primary" on:click={_addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button> <button class="btn" on:click={_addFolder}>{t('file.addFolderSimple')}</button>
</div> </div>
</div> </div>
{:else} {:else}
@@ -537,7 +739,7 @@
{onDragOver} {onDragOver}
{onDrop} {onDrop}
on:navigate={(e) => navigateToFolder(e.detail)} on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)} on:preview={(e) => _openFile(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)} on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)} on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)} on:delete={(e) => deleteFile(e.detail)}
@@ -557,19 +759,60 @@
{#if importing && !showImportDialog} {#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div> <div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if} {/if}
{/if}
</div>
<!-- Rename modal -->
{#if showRename}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
<div class="modal">
<h3>{t('rename.title')}</h3>
<div class="form-group">
<label><span class="label-text">{t('common.newName')}</span>
<input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} />
</label>
</div>
{#if renameError}
<div class="rename-error">{renameError}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<!-- Confirm modal -->
{#if showConfirm}
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmText={confirmText}
danger={confirmDanger}
on:confirm={handleConfirm}
on:cancel={handleCancel}
/>
{/if}
<!-- Import dialog --> <!-- Import dialog -->
{#if showImportDialog && importSummary} {#if showImportDialog && importSummary}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('file.importTitle')} on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}> <div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
<div class="modal-box" role="document"> <div class="modal">
<h3>{t('file.importTitle')}</h3> <h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
<div class="import-summary"> <div class="import-summary">
<p><strong>{t('file.path')}:</strong> {importSummary.path || pendingImportPath}</p> <div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
{#if importSummary.files} <div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
<p>{t('file.files')}: {importSummary.files.length}</p> <div class="summary-row"><span>{t('file.importSize')}</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
{#if importSummary.isDangerous}
<div class="summary-warn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{importSummary.dangerReason}
</div>
{/if} {/if}
</div> </div>
<div class="form-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button> <button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button> <button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button> <button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
@@ -578,39 +821,37 @@
</div> </div>
{/if} {/if}
<!-- File preview modal --> <!-- File preview modal (images, PDF, binary only) -->
{#if previewItem} {#if previewItem}
<FilePreviewModal <FilePreviewModal
item={previewItem} item={previewItem}
content={previewContent} content={previewContent}
loading={previewLoading} loading={previewLoading}
error={previewError} error={previewError}
{wailsCall}
on:close={closePreview} on:close={closePreview}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
/> />
{/if} {/if}
</div>
<style> <style>
.files-tab { padding: 20px; } .files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; } .files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.file-list { display: flex; flex-direction: column; } .file-list { display: flex; flex-direction: column; }
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; } .back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; } .import-summary { margin-bottom: 16px; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; } .summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
.empty-icon { margin-bottom: 12px; color: #333; } .summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
/* Button styles (mirroring global App.svelte .btn) */
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; } .btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; } .btn:hover { background: #222233; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; } .btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; } .btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; } .btn-sm { padding: 4px 10px; font-size: 12px; }
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; } .btn-danger { color: #ff6b6b; border-color: #4a2222; }
.modal-box { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; min-width: 320px; max-width: 560px; width: 90vw; } .btn-danger:hover { background: #3a2222; }
.modal-box h3 { margin: 0 0 16px; font-size: 16px; }
.import-summary { margin-bottom: 16px; font-size: 13px; }
.import-summary p { margin: 4px 0; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
</style> </style>
@@ -0,0 +1,345 @@
<script>
import { createEventDispatcher } from 'svelte'
import MarkdownEditor from './MarkdownEditor.svelte'
import MarkdownPreview from './MarkdownPreview.svelte'
import { isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n'
// ===== Props =====
export let content = ''
export let title = ''
export let subtitle = ''
export let loading = false
export let error = ''
export let dirty = false
export let isMarkdown = false
export let readonly = false
// ===== Events =====
const dispatch = createEventDispatcher()
// ===== State =====
let viewMode = 'edit' // 'edit' | 'preview' | 'split'
let editorRef = undefined
// ===== Public API =====
export function insertText(text) {
if (editorRef) editorRef.insertText(text)
}
// ===== Handlers =====
function handleContentChange(e) {
content = e.detail.content
dirty = true
dispatch('content-change', e.detail)
}
function handleSave() {
dispatch('save', { content })
}
function handleClose() {
dispatch('close')
}
function handleOpenExternal() {
dispatch('openExternal')
}
</script>
<div class="editor-panel">
<header class="ep-header">
<div class="ep-title-row">
{#if title}
<span class="ep-title" title={title}>{title}</span>
{/if}
{#if subtitle}
<span class="ep-subtitle">{subtitle}</span>
{/if}
{#if isMarkdown}
<span class="ep-badge">markdown</span>
{/if}
{#if dirty}
<span class="ep-dirty" title="Unsaved changes"></span>
{/if}
</div>
<div class="ep-actions">
{#if isMarkdown}
<div class="ep-mode-switcher" role="tablist" aria-label="View mode">
<button type="button" class="ep-mode-btn" class:active={viewMode === 'edit'} on:click={() => viewMode = 'edit'}>
{t('note.mode.edit')}
</button>
<button type="button" class="ep-mode-btn" class:active={viewMode === 'preview'} on:click={() => viewMode = 'preview'}>
{t('note.mode.preview')}
</button>
<button type="button" class="ep-mode-btn" class:active={viewMode === 'split'} on:click={() => viewMode = 'split'}>
{t('note.mode.split')}
</button>
</div>
{/if}
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div>
</header>
<div class="ep-body">
{#if loading}
<div class="ep-status"><p>{t('common.loading')}</p></div>
{:else if error}
<div class="ep-status ep-error">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div>
{:else if isMarkdown && viewMode === 'edit'}
<MarkdownEditor
bind:this={editorRef}
{content}
viewMode="edit"
on:content-change={handleContentChange}
on:save={handleSave}
/>
{:else if isMarkdown && viewMode === 'preview'}
<div class="ep-preview-pane">
<MarkdownPreview {content} />
</div>
{:else if isMarkdown && viewMode === 'split'}
<div class="ep-split">
<div class="ep-split-pane ep-split-editor">
<MarkdownEditor
bind:this={editorRef}
{content}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
/>
</div>
<div class="ep-split-pane ep-split-preview">
<MarkdownPreview {content} />
</div>
</div>
{:else}
<!-- Plain text / code -->
<textarea
class="ep-textarea"
bind:value={content}
on:input={() => { dirty = true; dispatch('content-change', { content }) }}
{readonly}
spellcheck="false"
></textarea>
{/if}
</div>
<footer class="ep-footer">
<div class="ep-footer-left">
{#if dirty && !readonly}
<span class="ep-dirty-hint">{t('editor.unsaved')}</span>
{/if}
</div>
<div class="ep-footer-right">
{#if !readonly}
<button class="btn btn-primary btn-sm" on:click={handleSave} disabled={!dirty}>{t('common.save')}</button>
{/if}
<button class="btn btn-sm" on:click={handleClose}>{t('common.close')}</button>
</div>
</footer>
</div>
<style>
.editor-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #13131f;
}
.ep-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
}
.ep-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.ep-title {
font-size: 13px;
font-weight: 500;
color: #e4e4ef;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ep-subtitle {
font-size: 11px;
color: #666;
flex-shrink: 0;
}
.ep-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #2a2a3c;
color: #888;
flex-shrink: 0;
}
.ep-dirty {
color: #f59e0b;
font-size: 8px;
flex-shrink: 0;
}
.ep-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ep-mode-switcher {
display: flex;
gap: 2px;
}
.ep-mode-btn {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.ep-mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.ep-mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
}
.ep-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ep-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 13px;
}
.ep-error {
color: #ff8888;
}
.ep-preview-pane {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.ep-split {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.ep-split-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.ep-split-editor {
border-right: 1px solid #2a2a3c;
}
.ep-split-preview {
background: #11111c;
overflow-y: auto;
padding: 16px 20px;
}
.ep-textarea {
flex: 1;
width: 100%;
min-height: 0;
border: none;
outline: none;
background: #13131f;
color: #e4e4ef;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.65;
padding: 16px 20px;
resize: none;
tab-size: 2;
overflow-y: auto;
}
.ep-textarea:read-only {
color: #999;
}
.ep-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.ep-footer-left {
flex: 1;
min-width: 0;
}
.ep-footer-right {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.ep-dirty-hint {
font-size: 11px;
color: #f59e0b;
}
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</style>
@@ -37,7 +37,7 @@
} }
</script> </script>
<div class="markdown-body" on:click={handleClick} role="article"> <div class="markdown-body" on:click={handleClick} role="article" tabindex="0" on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(e) }}>
{#if error} {#if error}
<div class="md-error"> <div class="md-error">
<p>⚠️ {t('note.preview.error')}</p> <p>⚠️ {t('note.preview.error')}</p>
@@ -1,188 +1,62 @@
<script> <script>
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte'
import MarkdownEditor from './MarkdownEditor.svelte'; import EditorPanel from './EditorPanel.svelte'
import MarkdownPreview from './MarkdownPreview.svelte'; import { t } from '../../i18n'
import { t } from '../../i18n';
export let content = ''; // ===== Props (note context) =====
export let viewMode = 'edit'; export let content = ''
export let placeholder = ''; export let viewMode = 'edit'
export let placeholder = ''
export let noteId = ''
export let noteTitle = ''
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher()
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor let editorRef = undefined
function setMode(mode) { // ===== Public API =====
dispatch('mode-change', { mode }); export function insertText(text) {
if (editorRef) editorRef.insertText(text)
} }
// ===== Handlers =====
function handleContentChange(e) { function handleContentChange(e) {
content = e.detail.content; content = e.detail.content
dispatch('content-change', e.detail); dispatch('content-change', e.detail)
} }
function handleSave() { function handleSave() {
dispatch('save'); dispatch('save')
} }
function handleInsertLink() { function handleInsertLink() {
dispatch('insert-link'); dispatch('insert-link')
} }
function handleInsertInternalLink() { function handleInsertInternalLink() {
dispatch('insert-internal-link'); dispatch('insert-internal-link')
} }
function handleVerstakLink(e) { function handleVerstakLink(e) {
dispatch('verstak-link', e.detail); dispatch('verstak-link', e.detail)
} }
// ─── Public API ────────────────────────────────────────────────── function handleModeChange(e) {
export function insertText(text) { viewMode = e.detail.mode
if (activeEditor && typeof activeEditor.insertText === 'function') { dispatch('mode-change', e.detail)
activeEditor.insertText(text);
}
} }
</script> </script>
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}> <EditorPanel
<!-- Mode switcher --> bind:this={editorRef}
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
{t('note.mode.edit')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
{t('note.mode.preview')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
{t('note.mode.split')}
</button>
</div>
<!-- Content area -->
<div class="panel-content">
{#if viewMode === 'edit'}
<MarkdownEditor
bind:this={activeEditor}
{content} {content}
{placeholder} title={noteTitle}
viewMode="edit" isMarkdown={true}
{viewMode}
on:content-change={handleContentChange} on:content-change={handleContentChange}
on:save={handleSave} on:save={handleSave}
on:mode-change={handleModeChange}
on:insert-link={handleInsertLink} on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink} on:insert-internal-link={handleInsertInternalLink}
on:verstak-link={handleVerstakLink}
on:close={() => dispatch('close')}
/> />
{:else if viewMode === 'preview'}
<div class="preview-pane">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
{:else if viewMode === 'split'}
<div class="split-pane">
<div class="split-editor">
<MarkdownEditor
bind:this={activeEditor}
{content}
{placeholder}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
</div>
<div class="split-preview">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
</div>
{/if}
</div>
</div>
<style>
.note-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.mode-switcher {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-bottom: 1px solid #2a2a3c;
background: #14141f;
flex-shrink: 0;
}
.mode-btn {
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
font-weight: 500;
}
.mode-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.panel-content {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.mode-preview .panel-content {
overflow-y: auto;
}
.preview-pane {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.split-pane {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.split-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #2a2a3c;
overflow: hidden;
}
.split-preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 20px 24px;
background: #11111c;
}
</style>
@@ -1,81 +1,82 @@
<script> <script>
import { t } from '../../i18n'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
// ===== Props =====
export let notes = []
export let formatDate = (str) => ''
// ===== Events =====
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let notes = [] // ===== Internal state =====
export let showCreateNote = false let showCreateNote = false
export let formatDate = (d) => d || ''
let newNoteTitle = '' let newNoteTitle = ''
function handleCreateNote() { function openCreateNote() {
dispatch('createNote') showCreateNote = true
}
function handleSubmitCreateNote() {
if (newNoteTitle.trim()) {
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
newNoteTitle = '' newNoteTitle = ''
} }
function cancelCreateNote() {
showCreateNote = false
newNoteTitle = ''
} }
function handleCancelCreateNote() { function submitCreateNote() {
dispatch('cancelCreateNote') if (!newNoteTitle.trim()) return
dispatch('submitCreateNote', { title: newNoteTitle.trim() })
showCreateNote = false
newNoteTitle = ''
} }
function handleOpenNote(note) { function handleCreateKeydown(e) {
if (e.key === 'Enter') submitCreateNote()
}
function openNote(note) {
dispatch('openNote', { note }) dispatch('openNote', { note })
} }
function handleStartRename(note) { function startRename(noteId, currentTitle) {
dispatch('startRename', { note }) dispatch('startRename', { noteId, currentTitle })
} }
function handleDelete(note) { function deleteNoteHandler(note) {
dispatch('delete', { note }) dispatch('deleteNote', { note })
}
function onKeyActivate(fn) {
return (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fn()
}
}
} }
</script> </script>
<div class="notes-tab"> <div class="notes-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={handleCreateNote}>{t('note.add')}</button> <button class="btn btn-primary" on:click={openCreateNote}>Добавить заметку</button>
</div> </div>
{#if showCreateNote} {#if showCreateNote}
<div class="create-form"> <div class="create-form">
<input type="text" placeholder={t('note.title')} bind:value={newNoteTitle} <input type="text" placeholder="Название заметки" bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && handleSubmitCreateNote()} /> on:keydown={handleCreateKeydown} />
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-primary" on:click={handleSubmitCreateNote}>{t('common.create')}</button> <button class="btn btn-primary" on:click={submitCreateNote}>Создать</button>
<button class="btn" on:click={handleCancelCreateNote}>{t('common.cancel')}</button> <button class="btn" on:click={cancelCreateNote}>Отмена</button>
</div> </div>
</div> </div>
{/if} {/if}
{#if notes.length === 0 && !showCreateNote} {#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div> <div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку</p></div>
{:else} {:else if notes.length > 0}
<div class="notes-list"> <div class="notes-list">
{#each notes as note} {#each notes as note}
<div class="note-card" role="button" tabindex="0" on:click={() => handleOpenNote(note)} on:keydown={onKeyActivate(() => handleOpenNote(note))}> <div class="note-card" role="button" tabindex="0" on:click={() => openNote(note)} on:keydown={(e) => e.key === 'Enter' && openNote(note)}>
<div class="note-card-info"> <div class="note-card-info">
<div class="note-card-title">{note.title}</div> <div class="note-card-title">{note.title}</div>
<div class="note-card-date">{formatDate(note.createdAt)}</div> <div class="note-card-date">{formatDate(note.createdAt)}</div>
</div> </div>
<div class="note-card-actions" on:click|stopPropagation> <div class="note-card-actions" on:click|stopPropagation>
<button class="note-action-btn" on:click={() => handleStartRename(note)} title={t('common.rename')}> <button class="note-action-btn" on:click={() => startRename(note.id, note.title)} title="Переименовать">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
</button> </button>
<button class="note-action-btn note-action-danger" on:click={() => handleDelete(note)} title={t('common.delete')}> <button class="note-action-btn note-action-danger" on:click={() => deleteNoteHandler(note)} title="Удалить">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
</div> </div>
@@ -89,16 +90,16 @@
.notes-tab { padding: 24px; } .notes-tab { padding: 24px; }
.tab-toolbar { margin-bottom: 16px; } .tab-toolbar { margin-bottom: 16px; }
.create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; } .create-form { background: #1a1a28; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; outline: none; } .create-form input { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 8px; }
.create-form input:focus { border-color: #6366f1; } .create-form input:focus { outline: none; border-color: #6366f1; }
.form-actions { display: flex; gap: 8px; } .form-actions { display: flex; gap: 8px; }
.notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } .notes-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; } .note-card { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 16px; cursor: pointer; position: relative; }
.note-card:hover { border-color: #3a3a5c; } .note-card:hover { border-color: #3a3a5c; }
.note-card-info { flex: 1; min-width: 0; }
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; } .note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.note-card-date { font-size: 11px; color: #555; } .note-card-date { font-size: 11px; color: #555; }
.note-card-info { flex: 1; min-width: 0; } .note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; position: absolute; top: 8px; right: 8px; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
.note-card:hover .note-card-actions { opacity: 1; } .note-card:hover .note-card-actions { opacity: 1; }
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; } .note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
.note-action-btn:hover { background: #2a2a3c; color: #ccc; } .note-action-btn:hover { background: #2a2a3c; color: #ccc; }
@@ -106,9 +107,14 @@
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; } .empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; } .empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
/* Button styles (mirroring global App.svelte .btn) */
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; } .btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; } .btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; } .btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; } .btn-primary:hover { background: #4f46e5; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; } .btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
</style> </style>
+2 -2
View File
@@ -78,8 +78,8 @@ export function getFileKind(item) {
} }
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml'] const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript'] const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript','text/csv','text/tab-separated-values','text/x-python','text/x-java','text/x-c','text/x-cpp','text/x-ruby','text/x-perl','text/x-php','text/x-go','text/x-rust','text/markdown']
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg'] const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','tsv','sh','py','js','ts','css','html','md','markdown','cfg','env','gitignore','dockerignore','toml','bat','cmd','ps1','sql','graphql','proto','gradle','cmake','makefile','dockerfile','vbs','lua','r','m','scala','kt','swift','dart','elm','erl','ex','exs','fs','fsi','fsx','hs','lhs','ml','mli','pl','pm','rb','rake','rs','scm','ss','clj','cljs','cljc','edn','lisp','lsp','el','vim','vimrc','zsh','bash','fish','csh','ksh','tcsh','awk','sed','make','mk','nim','nims','d','go','java','svelte','vue','jsx','tsx','coffee','litcoffee','scss','sass','less','styl','properties','dotenv','editorconfig','gitattributes','browserslistrc','htaccess','nginx','shader','glsl','vert','frag','asm','h','hpp','cxx','cc','cpp','capnp','flatbuf','prisma','dtd','xsl','xsd','sch','svg','vtt','srt','sub','m3u','m3u8','pls','xspf','cue','toc','nfo','diz','readme','changelog','copying','license','authors','contributors','setup','config','strings','po','pot','locale','translation']
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg'] const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
-9
View File
@@ -1,9 +0,0 @@
/**
* Actions API actions (quick commands) on nodes.
*/
import { createApi } from './wails.js'
export const listActions = createApi('ListActions')
export const createAction = createApi('CreateAction')
export const runAction = createApi('RunAction')
export const deleteAction = createApi('DeleteAction')
-7
View File
@@ -1,7 +0,0 @@
/**
* Activity API activity feed and per-node activity.
*/
import { createApi } from './wails.js'
export const listActivityFeed = createApi('ListActivityFeed')
export const listActivityByNode = createApi('ListActivityByNode')
@@ -1,9 +0,0 @@
/**
* Browser Events API browser extension events.
*/
import { createApi } from './wails.js'
export const listBrowserEvents = createApi('ListBrowserEvents')
export const acceptBrowserEvent = createApi('AcceptBrowserEvent')
export const dismissBrowserEvent = createApi('DismissBrowserEvent')
export const attachBrowserEventToNode = createApi('AttachBrowserEventToNode')
-20
View File
@@ -1,20 +0,0 @@
/**
* Files API all file/folder related Wails calls.
*/
import { createApi } from './wails.js'
export const listItems = createApi('ListItems')
export const listFiles = createApi('ListFiles')
export const createEmptyFile = createApi('CreateEmptyFile')
export const deleteFileOrFolder = createApi('DeleteFileOrFolder')
export const openFile = createApi('OpenFile')
export const openFolder = createApi('OpenFolder')
export const pickFile = createApi('PickFile')
export const pickDirectory = createApi('PickDirectory')
export const previewImport = createApi('PreviewImport')
export const addPathCopy = createApi('AddPathCopy')
export const addPathLink = createApi('AddPathLink')
export const checkFileAction = createApi('CheckFileAction')
export const getFileBase64 = createApi('GetFileBase64')
export const readFileText = createApi('ReadFileText')
export const showInFolder = createApi('OpenFolder')
-14
View File
@@ -1,14 +0,0 @@
/**
* Inbox API capture and inbox management.
*/
import { createApi } from './wails.js'
export const listInboxNodes = createApi('ListInboxNodes')
export const listInboxNodesForTarget = createApi('ListInboxNodesForTarget')
export const captureClipboardTextWithContext = createApi('CaptureClipboardTextWithContext')
export const captureTextWithContext = createApi('CaptureTextWithContext')
export const captureURLWithContext = createApi('CaptureURLWithContext')
export const capturePathWithContext = createApi('CapturePathWithContext')
export const captureFileDataWithContext = createApi('CaptureFileDataWithContext')
export const resolveInboxNode = createApi('ResolveInboxNode')
export const deleteInboxNode = createApi('DeleteInboxNode')
-13
View File
@@ -1,13 +0,0 @@
/**
* Journal & Worklog API time tracking and reporting.
*/
import { createApi } from './wails.js'
export const listWorklog = createApi('ListWorklog')
export const createWorklogFull = createApi('CreateWorklogFull')
export const updateWorklogEntry = createApi('UpdateWorklogEntry')
export const deleteWorklogEntry = createApi('DeleteWorklogEntry')
export const getWorklogEntryEvents = createApi('GetWorklogEntryEvents')
export const listWorklogReport = createApi('ListWorklogReport')
export const worklogReportSummary = createApi('WorklogReportSummary')
export const saveWorklogReport = createApi('SaveWorklogReport')
-9
View File
@@ -1,9 +0,0 @@
/**
* Links API external URL links management.
*/
import { createApi } from './wails.js'
export const listLinks = createApi('ListLinks')
export const updateLink = createApi('UpdateLink')
export const deleteLink = createApi('DeleteLink')
export const openLink = createApi('OpenLink')
-20
View File
@@ -1,20 +0,0 @@
/**
* Node & Workspace API tree, node CRUD, system views.
*/
import { createApi } from './wails.js'
export const getStartupStatus = createApi('GetStartupStatus')
export const verstakVersion = createApi('VerstakVersion')
export const listSystemViewsWithPlugins = createApi('ListSystemViewsWithPlugins')
export const listWorkspaceTree = createApi('ListWorkspaceTree')
export const listWorkspaceChildren = createApi('ListWorkspaceChildren')
export const listEnabledTemplates = createApi('ListEnabledTemplates')
export const createNodeFromTemplate = createApi('CreateNodeFromTemplate')
export const deleteNode = createApi('DeleteNode')
export const renameNode = createApi('RenameNode')
export const moveNode = createApi('MoveNode')
export const duplicateNode = createApi('DuplicateNode')
export const getNodeDetail = createApi('GetNodeDetail')
export const getSuggestions = createApi('GetSuggestions')
export const validateName = createApi('ValidateName')
export const writeDebugLog = createApi('WriteDebugLog')
-11
View File
@@ -1,11 +0,0 @@
/**
* Notes API all note-related Wails calls.
*/
import { createApi } from './wails.js'
export const listNotes = createApi('ListNotes')
export const createNote = createApi('CreateNote')
export const readNote = createApi('ReadNote')
export const saveNote = createApi('SaveNote')
export const renameNote = createApi('RenameNote')
export const deleteNoteApi = createApi('DeleteNote')
-8
View File
@@ -1,8 +0,0 @@
/**
* Search API global search and node lookup.
*/
import { createApi } from './wails.js'
export const searchNodes = createApi('SearchNodes')
export const getNodeDetail = createApi('GetNodeDetail')
export const searchWorkspace = createApi('SearchWorkspace')
-9
View File
@@ -1,9 +0,0 @@
/**
* Suggestions API worklog suggestions.
*/
import { createApi } from './wails.js'
export const getSuggestions = createApi('GetSuggestions')
export const acceptSuggestionFull = createApi('AcceptSuggestionFull')
export const dismissSuggestion = createApi('DismissSuggestion')
export const acceptSuggestionWith = createApi('AcceptSuggestionWith')
-7
View File
@@ -1,7 +0,0 @@
/**
* Sync API vault synchronisation.
*/
import { createApi } from './wails.js'
export const syncStatus = createApi('SyncStatus')
export const syncNow = createApi('SyncNow')
-8
View File
@@ -1,8 +0,0 @@
/**
* Today API today dashboard, in-progress items, captures.
*/
import { createApi } from './wails.js'
export const listTodayView = createApi('ListTodayView')
export const listTodayInProgress = createApi('ListTodayInProgress')
export const listTodayCaptures = createApi('ListTodayCaptures')
-12
View File
@@ -1,12 +0,0 @@
/**
* Trash API trash operations.
*/
import { createApi } from './wails.js'
export const listTrash = createApi('ListTrash')
export const trashCount = createApi('TrashCount')
export const readTrashFile = createApi('ReadTrashFile')
export const readTrashFileContent = createApi('ReadTrashFileContent')
export const restoreTrashNodes = createApi('RestoreTrashNodesJSON')
export const purgeTrashNodes = createApi('PurgeTrashNodesJSON')
export const emptyTrash = createApi('EmptyTrash')
-27
View File
@@ -1,27 +0,0 @@
/**
* Base Wails API call helper.
* All backend communication goes through this function.
*/
export function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const fn = window['go']['main']['App'][method]
if (typeof fn === 'function') {
return fn(...args)
}
}
} catch (e) {
console.error('Wails call error:', method, e)
}
return Promise.reject(new Error('Wails not connected: ' + method))
}
/**
* Create a wrapped API function with consistent error handling.
*/
export function createApi(method) {
return (...args) => {
return wailsCall(method, ...args)
}
}
+43
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>'
}
}
+21 -1
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()
} }
+22
View File
@@ -330,6 +330,28 @@ func (s *Service) ReadText(id string) (string, error) {
return string(b), nil return string(b), nil
} }
// WriteText writes text content to a file on disk and updates the record.
func (s *Service) WriteText(rec *Record, content string) error {
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
tmp := abs + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
return fmt.Errorf("write temp: %w", err)
}
if err := os.Rename(tmp, abs); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
sum := sha256.Sum256([]byte(content))
_, err = s.db.Exec(
`UPDATE files SET size=?, sha256=?, updated_at=?, missing=? WHERE id=?`,
len(content), fmt.Sprintf("%x", sum[:]), now, 0, rec.ID)
return err
}
// ReadBase64 reads a file and returns a data URI (base64-encoded). // ReadBase64 reads a file and returns a data URI (base64-encoded).
func (s *Service) ReadBase64(id string) (string, error) { func (s *Service) ReadBase64(id string) (string, error) {
rec, err := s.Get(id) rec, err := s.Get(id)