Author SHA1 Message Date
mirivlad 2487d3bbaa Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons 2026-06-01 01:16:51 +08:00
mirivlad 645d8878cc gui: complete Wails v2 vertical MVP — fixes, search, polished UI
Backend fixes:
- Wire search service in App struct, implement Search() bindings
- Fix OpenFile to use files.Service.Open() instead of stub
- Fix OpenFolder to open spaces/<slug>/ instead of vault root
- Remove unused imports and dead code in app.go

Frontend fixes:
- Add missing Svelte plugin to vite.config.js (blocking build error)
- Fix optional catch binding for compatibility
- Fix select dropdown rendering on Linux (appearance: none + custom arrow)
- Switch api/verstak.js to use generated Wails v2 bindings
- Include hand-written wailsjs bindings in repository
- Add build.sh to repository

Build:
  cd frontend && npm run build
  rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
  go build -tags 'gui production webkit2_41' -o verstak-gui ./cmd/verstak-gui
2026-05-31 23:48:38 +08:00
35 changed files with 2935 additions and 317 deletions
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
+149 -19
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -13,10 +14,13 @@ import (
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
)
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
ctx context.Context
@@ -26,12 +30,18 @@ type App struct {
notes *notes.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
vault string
}
// startup is called when the app starts. Store context.
// startup is called when the app starts. Store context and wire drag-and-drop.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
if len(paths) > 0 {
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
}
})
}
// ============================================================
@@ -72,6 +82,16 @@ type FileDTO struct {
Missing bool `json:"missing"`
}
type FileTreeItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "folder" | "file"
FileID string `json:"fileId,omitempty"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
HasKids bool `json:"hasKids"`
}
type ActionDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
@@ -196,6 +216,7 @@ func (a *App) SaveNote(noteID, content string) error {
// Files
// ============================================================
// ListFiles returns file records directly linked to a node (non-recursive).
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
records, err := a.files.ListByNode(nodeID)
if err != nil {
@@ -203,22 +224,105 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
}
result := make([]FileDTO, len(records))
for i := range records {
isDir := records[i].MIME == "inode/directory"
missing := false
rec := &records[i]
result[i] = FileDTO{
ID: records[i].ID,
NodeID: records[i].NodeID,
Name: records[i].Filename,
Path: records[i].Path,
Size: records[i].Size,
Mime: records[i].MIME,
IsDir: isDir,
Missing: missing,
ID: rec.ID,
NodeID: rec.NodeID,
Name: rec.Filename,
Path: rec.Path,
Size: rec.Size,
Mime: rec.MIME,
IsDir: rec.MIME == "inode/directory",
Missing: rec.Missing,
}
}
return result, nil
}
// ListItems returns children of a node for the file tree view.
// Folders can be expanded; files include their file record info.
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
continue
}
item := FileTreeItemDTO{
ID: children[i].ID,
Name: children[i].Title,
Type: children[i].Type,
}
if children[i].Type == nodes.TypeFolder {
// Check if this folder has children
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
} else if children[i].Type == nodes.TypeFile {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = records[0].MIME
}
}
result = append(result, item)
}
return result, nil
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
}
return toNodeDTOs(nodes), nil
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
return a.files.DeleteNodeAndChildren(nodeID)
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
return a.nodes.Move(nodeID, newParentID, 0)
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
return a.files.PreviewImport(sourcePath)
}
// ============================================================
// Actions
// ============================================================
@@ -296,14 +400,27 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
}
// ============================================================
// Search (stubs — search service not wired yet)
// Search
// ============================================================
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if strings.TrimSpace(query) == "" {
return []SearchResultDTO{}, nil
}
return []SearchResultDTO{}, nil
results, err := a.search.Search(query)
if err != nil {
return nil, err
}
out := make([]SearchResultDTO, len(results))
for i, r := range results {
out[i] = SearchResultDTO{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
}
}
return out, nil
}
// ============================================================
@@ -333,11 +450,27 @@ func (a *App) PickDirectory() (string, error) {
// ============================================================
func (a *App) OpenFile(fileID string) error {
return fmt.Errorf("not implemented: %s", fileID)
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
cmd := exec.Command("xdg-open", a.vault)
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return fmt.Errorf("get node: %w", err)
}
dir := filepath.Join(a.vault, "spaces", n.Slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
dir = a.vault
}
cmd := exec.Command("xdg-open", dir)
return cmd.Run()
}
@@ -377,7 +510,4 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO {
return result
}
var (
_ = os.Getenv
_ = exec.Command
)
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.svelte-14ysusk.svelte-14ysusk,.svelte-14ysusk.svelte-14ysusk:before,.svelte-14ysusk.svelte-14ysusk:after{box-sizing:border-box;margin:0;padding:0}.app.svelte-14ysusk.svelte-14ysusk{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}.sidebar.svelte-14ysusk.svelte-14ysusk{width:260px;min-width:200px;height:100vh;display:flex;flex-direction:column;background:#1a1a28;border-right:1px solid #2a2a3c;flex-shrink:0;overflow:hidden}.sidebar-top.svelte-14ysusk.svelte-14ysusk{padding:16px 20px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #2a2a3c;flex-shrink:0}.logo.svelte-14ysusk.svelte-14ysusk{font-size:20px;line-height:1}.app-name.svelte-14ysusk.svelte-14ysusk{font-size:16px;font-weight:600;color:#e4e4ef}.sidebar-nav.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:12px 0}.nav-group.svelte-14ysusk.svelte-14ysusk{margin-bottom:16px}.nav-label.svelte-14ysusk.svelte-14ysusk{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#666;padding:4px 20px;margin-bottom:4px}.nav-item.svelte-14ysusk.svelte-14ysusk{display:block;width:100%;padding:8px 20px;border:none;background:none;color:#ccc;font-size:13px;text-align:left;cursor:pointer;border-radius:0;font-family:inherit}.nav-item.svelte-14ysusk.svelte-14ysusk:hover{background:#223}.nav-item.selected.svelte-14ysusk.svelte-14ysusk{background:#2a2a4a;color:#fff;font-weight:500}.nav-empty.svelte-14ysusk.svelte-14ysusk{padding:8px 20px;color:#555;font-size:12px}.sidebar-bottom.svelte-14ysusk.svelte-14ysusk{padding:12px 20px;border-top:1px solid #2a2a3c;flex-shrink:0}.version.svelte-14ysusk.svelte-14ysusk{font-size:11px;color:#555}.main.svelte-14ysusk.svelte-14ysusk{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0;overflow:hidden;background:#13131f}.header.svelte-14ysusk.svelte-14ysusk{padding:12px 24px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;min-height:48px}.crumb.svelte-14ysusk.svelte-14ysusk{font-size:14px;font-weight:500;color:#e4e4ef}.crumb.placeholder.svelte-14ysusk.svelte-14ysusk{color:#666}.search-hint.svelte-14ysusk.svelte-14ysusk{padding:6px 12px;background:#1e1e2e;border:1px solid #2a2a3c;border-radius:4px;color:#666;font-size:12px;cursor:text}.error-banner.svelte-14ysusk.svelte-14ysusk{background:#3a2222;color:#f88;padding:8px 24px;font-size:12px;border-bottom:1px solid #4a2222;flex-shrink:0}.content.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:24px}.welcome.svelte-14ysusk h2.svelte-14ysusk{font-size:28px;font-weight:300;margin-bottom:12px;color:#8888a4}.welcome.svelte-14ysusk p.svelte-14ysusk{color:#666;font-size:13px;margin-bottom:4px}.error-text.svelte-14ysusk.svelte-14ysusk{color:#f88;margin-top:12px}.loading.svelte-14ysusk.svelte-14ysusk{color:#666}.node-view.svelte-14ysusk h2.svelte-14ysusk{font-size:24px;margin-bottom:16px}.node-meta.svelte-14ysusk.svelte-14ysusk{display:flex;gap:16px;color:#666;font-size:12px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
@@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/index-COs6tJEl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
</head>
<body>
<div id="app"></div>
+5 -2
View File
@@ -43,13 +43,12 @@ func main() {
// Init core services
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
_ = searchSvc
app := &App{
db: db,
@@ -58,6 +57,7 @@ func main() {
notes: noteSvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
vault: abs,
}
@@ -72,6 +72,9 @@ func main() {
Assets: assets,
},
OnStartup: app.startup,
DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
},
Bind: []interface{}{app},
})
+1071 -234
View File
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
export let item
export let selected = false
export let onDragStart
export let onDragOver
export let onDrop
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
const isFolder = item.type === 'folder'
let menuOpen = false
let clickTimer = null
function handleClick(e) {
if (e.ctrlKey || e.metaKey) {
dispatch('toggleSelect', item.id)
} else if (e.shiftKey) {
dispatch('rangeSelect', item.id)
} else {
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
// Double click
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
clickTimer = setTimeout(() => {
clickTimer = null
// Single click: select
if (selected) {
// Already selected: navigate/preview
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
dispatch('selectOne', item.id)
}
}, 250)
}
}
}
function handleKeydown(e) {
if (e.key === 'Enter') {
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
function handleDelete() {
dispatch('delete', { id: item.id, type: item.type })
}
function handleRename() {
menuOpen = false
dispatch('rename', { id: item.id, name: item.name })
}
function handleDuplicate() {
menuOpen = false
dispatch('duplicate', item.id)
}
function handleCut() {
menuOpen = false
dispatch('cut', item.id)
}
function handleCopy() {
menuOpen = false
dispatch('copy', item.id)
}
function toggleMenu() {
menuOpen = !menuOpen
}
function closeMenu() {
menuOpen = false
}
function handleDragStart(e) {
if (onDragStart) onDragStart(e, item.id)
}
function handleDragOver(e) {
if (onDragOver && isFolder) onDragOver(e, item.id)
}
function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id)
}
</script>
<svelte:window on:click={closeMenu}/>
<div class="file-row"
class:file-row--selected={selected}
class:file-row--dragover={false}
role="button"
tabindex="0"
draggable="true"
on:click={handleClick}
on:keydown={handleKeydown}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
<div class="file-row-icon">
<FileIcon {kind} size={22}/>
</div>
<div class="file-row-body">
<div class="file-row-name" title={item.name}>{item.name}</div>
<div class="file-row-meta">
{#if isFolder}
<span>Folder</span>
{:else}
<span>{formatFileSize(item.size)}</span>
{#if item.mime}
<span class="meta-sep">·</span>
<span>{formatMimeType(item.mime)}</span>
{/if}
{/if}
</div>
</div>
<div class="file-row-actions">
{#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<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>
{:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
{/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
<svg width="16" height="16" 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>
</div>
</div>
{#if menuOpen}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
</div>
{/if}
<style>
.file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: default;
transition: background 0.12s;
min-height: 52px;
user-select: none;
}
.file-row:hover {
background: #1e1e30;
}
.file-row--selected {
background: #1e1e3a;
outline: 1px solid #3a3a6c;
}
.file-row--selected:hover {
background: #252545;
}
.file-row:focus-visible {
outline: 2px solid #5588ff;
outline-offset: -2px;
}
.file-row-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: #888;
}
.file-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-row-name {
font-size: 13px;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.file-row-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.meta-sep {
color: #444;
}
.file-row-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
flex-shrink: 0;
}
.file-row:hover .file-row-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn-danger:hover {
background: #3a2222;
color: #ff6b6b;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.menu {
position: absolute;
right: 12px;
margin-top: 4px;
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 6px;
padding: 4px;
z-index: 100;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.menu-item {
display: block;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
color: #ccc;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-family: inherit;
}
.menu-item:hover {
background: #2a2a3c;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+36 -36
View File
@@ -1,43 +1,43 @@
// Wails v2 API wrapper — single frontend access point to Go backend
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
import * as App from '../wailsjs/go/main/App.js'
function wailsCall(method, ...args) {
if (window.go && window.go.main && window.go.main.App) {
return window.go.main.App[method](...args)
}
return Promise.reject(new Error('Wails bindings not loaded'))
}
// Re-export all methods
export const listSections = () => App.ListSections()
export const listNodesBySection = (section) => App.ListNodesBySection(section)
export const listChildren = (parentID) => App.ListChildren(parentID)
export const getNodeDetail = (id) => App.GetNodeDetail(id)
export const createNode = (parentID, type, title, section) => App.CreateNode(parentID, type, title, section)
export const deleteNode = (id) => App.DeleteNode(id)
// Sections
export const listSections = () => wailsCall('ListSections')
export const listNotes = (nodeID) => App.ListNotes(nodeID)
export const createNote = (parentID, title) => App.CreateNote(parentID, title)
export const readNote = (noteID) => App.ReadNote(noteID)
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
// Nodes
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
export const createNode = (parentID, type, title, section) =>
wailsCall('CreateNode', parentID, type, title, section)
export const deleteNode = (id) => wailsCall('DeleteNode', id)
export const listFiles = (nodeID) => App.ListFiles(nodeID)
export const listItems = (nodeID) => App.ListItems(nodeID)
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
export const pickFile = () => App.PickFile()
export const pickFiles = () => App.PickFiles()
export const pickDirectory = () => App.PickDirectory()
export const openFile = (id) => App.OpenFile(id)
export const readFileText = (id) => App.ReadFileText(id)
export const getFileBase64 = (id) => App.GetFileBase64(id)
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
// Notes
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
export const createNote = (parentID, title) => wailsCall('CreateNote', parentID, title)
export const readNote = (noteID) => wailsCall('ReadNote', noteID)
export const saveNote = (noteID, content) => wailsCall('SaveNote', noteID, content)
export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id)
// Files
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
// Actions
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
export const runAction = (id) => wailsCall('RunAction', id)
export const search = (query) => App.Search(query)
// Worklog
export const listWorklog = (nodeID) => wailsCall('ListWorklog', nodeID)
export const createWorklog = (nodeID, summary, minutes) =>
wailsCall('CreateWorklog', nodeID, summary, minutes)
// Search
export const search = (query) => wailsCall('Search', query)
// System
export const verstakVersion = () => wailsCall('VerstakVersion')
export const verstakVersion = () => App.VerstakVersion()
+101
View File
@@ -0,0 +1,101 @@
<script>
export let title = 'Подтверждение'
export let message = ''
export let confirmText = 'Удалить'
export let cancelText = 'Отмена'
export let danger = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
<div class="modal">
<h3>{title}</h3>
<p class="message">{message}</p>
<div class="actions">
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 12px;
padding: 24px;
width: 360px;
max-width: 90vw;
}
h3 {
font-size: 18px;
margin-bottom: 12px;
color: #e4e4ef;
}
.message {
font-size: 14px;
color: #aaa;
margin-bottom: 20px;
line-height: 1.4;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.btn:hover {
background: #222233;
}
.btn-primary {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script>
import { createEventDispatcher } from 'svelte'
export let isFolder = false
export let fileId = ''
export let nodeId = ''
const dispatch = createEventDispatcher()
function handleOpen() {
if (isFolder) {
dispatch('openFolder', nodeId)
} else {
dispatch('open', fileId)
}
}
function handleDelete() {
dispatch('delete', nodeId)
}
</script>
<div class="file-actions">
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if isFolder}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
{:else}
<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"/>
{/if}
</svg>
</button>
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
<svg width="16" height="16" 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>
</div>
<style>
.file-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
}
:global(.file-row:hover) .file-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn-danger:hover {
background: #3a2222;
color: #ff6b6b;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script>
import { createEventDispatcher } from 'svelte'
export let crumbs = []
const dispatch = createEventDispatcher()
function navigateTo(index) {
dispatch('navigate', index)
}
</script>
<nav class="breadcrumbs">
{#each crumbs as crumb, i}
{#if i > 0}
<span class="sep">/</span>
{/if}
{#if i === crumbs.length - 1}
<span class="crumb crumb--current">{crumb.name}</span>
{:else}
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
{/if}
{/each}
</nav>
<style>
.breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
font-size: 13px;
color: #999;
}
.sep {
color: #444;
}
.crumb {
font-size: 13px;
}
.crumb--current {
color: #ccc;
}
.crumb--link {
background: none;
border: none;
padding: 2px 4px;
color: #888;
cursor: pointer;
border-radius: 3px;
font-family: inherit;
font-size: 13px;
transition: color 0.12s, background 0.12s;
}
.crumb--link:hover {
color: #ccc;
background: #1e1e30;
}
.crumb--link:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script>
export let kind = 'generic'
export let size = 20
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
{#if kind === 'folder'}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
{:else if kind === 'image'}
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
{:else if kind === 'video'}
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="10 9 16 12 10 15 10 9"/>
{:else if kind === 'audio'}
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
{:else if kind === 'pdf'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
{:else if kind === 'document'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
{:else if kind === 'spreadsheet'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
<line x1="12" y1="12" x2="12" y2="18"/>
{:else if kind === 'presentation'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="12" x2="15" y2="12"/>
<line x1="9" y1="15" x2="13" y2="15"/>
<line x1="12" y1="15" x2="12" y2="18"/>
{:else if kind === 'archive'}
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<polyline points="7 3 12 8 17 3"/>
<line x1="3" y1="8" x2="21" y2="8"/>
<rect x="10" y="12" width="4" height="4" rx="1"/>
{:else if kind === 'code'}
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
{:else if kind === 'text'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
{:else}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
{/if}
</svg>
+262
View File
@@ -0,0 +1,262 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
export let item
export let content = '' // base64 data URI or text content
export let loading = false
export let error = ''
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
$: showImage = isImageFile(item) && content && content.startsWith('data:')
$: showText = isTextFile(item) || isMarkdownFile(item)
$: showPdf = isPdfFile(item)
function handleKeydown(e) {
if (e.key === 'Escape') {
dispatch('close')
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
onMount(() => {
window.addEventListener('keydown', handleKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
<div class="modal">
<header class="preview-header">
<div class="preview-title">
<FileIcon {kind} size={18}/>
<span class="preview-name" title={item.name}>{item.name}</span>
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<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">
<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="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</header>
<div class="preview-body">
{#if loading}
<div class="preview-status"><p>Loading preview...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{:else if showImage && content}
<div class="preview-image-container">
<img src={content} alt={item.name} class="preview-image"/>
</div>
{:else if showText && content}
<pre class="preview-text"><code>{content}</code></pre>
{:else if showPdf}
{#if content && content.startsWith('data:')}
<div class="preview-pdf-container">
<embed src={content} type="application/pdf" class="preview-pdf"/>
</div>
{:else}
<div class="preview-status">
<p>PDF preview is not available in this environment.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Preview is not available for this file type.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #14141f;
border: 1px solid #2a2a3c;
border-radius: 10px;
width: 90vw;
max-width: 900px;
height: 85vh;
max-height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3c;
flex-shrink: 0;
}
.preview-title {
display: flex;
align-items: center;
gap: 8px;
color: #ddd;
font-size: 14px;
min-width: 0;
}
.preview-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 11px;
color: #666;
margin-left: auto;
white-space: nowrap;
}
.preview-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.action-btn-close {
color: #ff6b6b;
}
.action-btn-close:hover {
background: #3a2222;
color: #ff4444;
}
.preview-body {
flex: 1;
overflow: auto;
min-height: 0;
}
.preview-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 14px;
}
.preview-image-container {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 200px;
background: #0e0e18;
}
.preview-image {
max-width: 100%;
max-height: calc(85vh - 100px);
object-fit: contain;
border-radius: 4px;
}
.preview-text {
margin: 0;
padding: 16px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
color: #ccc;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
}
.preview-pdf-container {
width: 100%;
height: 100%;
}
.preview-pdf {
width: 100%;
height: 100%;
border: none;
}
.btn-sm {
padding: 6px 14px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
transition: background 0.12s;
}
.btn-sm:hover {
background: #222233;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
export function formatFileSize(bytes) {
if (bytes == null || bytes < 0) return '—'
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const val = bytes / Math.pow(1024, i)
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
}
const mimeLabels = {
'image/jpeg': 'JPEG image',
'image/png': 'PNG image',
'image/gif': 'GIF image',
'image/webp': 'WebP image',
'image/svg+xml': 'SVG image',
'image/bmp': 'BMP image',
'image/tiff': 'TIFF image',
'image/avif': 'AVIF image',
'application/pdf': 'PDF document',
'application/msword': 'Word document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
'application/vnd.ms-excel': 'Excel spreadsheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
'application/zip': 'ZIP archive',
'application/gzip': 'GZIP archive',
'application/x-tar': 'TAR archive',
'application/x-7z-compressed': '7z archive',
'application/x-rar-compressed': 'RAR archive',
'text/plain': 'Text file',
'text/html': 'HTML file',
'text/css': 'CSS file',
'text/javascript': 'JavaScript file',
'application/json': 'JSON file',
'application/xml': 'XML file',
'application/x-yaml': 'YAML file',
'application/octet-stream': 'Binary file',
'application/x-msdos-program': 'Executable',
'inode/directory': 'Folder',
}
export function formatMimeType(mime) {
if (!mime) return 'Unknown'
return mimeLabels[mime] || mime
}
export function getFileKind(item) {
if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('text/')) return 'text'
if (mime.includes('pdf')) return 'pdf'
if (mime.includes('word') || mime.includes('document')) return 'document'
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
if (codeExts.includes(ext)) return 'code'
return 'generic'
}
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 codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
export function canPreviewFile(item) {
if (item.type === 'folder') return false
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
if (mime.includes('pdf')) return true
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
return false
}
export function isImageFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return imageMimes.includes(mime) || imageExts.includes(ext)
}
export function isTextFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
}
export function isPdfFile(item) {
return (item.mime || '').toLowerCase().includes('pdf')
}
export function isMarkdownFile(item) {
const name = (item.name || '').toLowerCase()
return name.endsWith('.md') || name.endsWith('.markdown')
}
export function needsBase64Preview(item) {
return isImageFile(item) || isPdfFile(item)
}
export function needsTextPreview(item) {
return isTextFile(item) || isMarkdownFile(item)
}
+107
View File
@@ -0,0 +1,107 @@
// @ts-check
// Wails v2 generated bindings — auto-generated by wails build
// Manual version for go build -tags gui
export function ListSections() {
return window['go']['main']['App']['ListSections']();
}
export function ListNodesBySection(arg1) {
return window['go']['main']['App']['ListNodesBySection'](arg1);
}
export function ListChildren(arg1) {
return window['go']['main']['App']['ListChildren'](arg1);
}
export function GetNodeDetail(arg1) {
return window['go']['main']['App']['GetNodeDetail'](arg1);
}
export function CreateNode(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['CreateNode'](arg1, arg2, arg3, arg4);
}
export function DeleteNode(arg1) {
return window['go']['main']['App']['DeleteNode'](arg1);
}
export function ListNotes(arg1) {
return window['go']['main']['App']['ListNotes'](arg1);
}
export function CreateNote(arg1, arg2) {
return window['go']['main']['App']['CreateNote'](arg1, arg2);
}
export function ReadNote(arg1) {
return window['go']['main']['App']['ReadNote'](arg1);
}
export function SaveNote(arg1, arg2) {
return window['go']['main']['App']['SaveNote'](arg1, arg2);
}
export function ListFiles(arg1) {
return window['go']['main']['App']['ListFiles'](arg1);
}
export function AddPathCopy(arg1, arg2) {
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
}
export function AddPathLink(arg1, arg2) {
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
}
export function DeleteFileOrFolder(arg1) {
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
}
export function PreviewImport(arg1) {
return window['go']['main']['App']['PreviewImport'](arg1);
}
export function ListActions(arg1) {
return window['go']['main']['App']['ListActions'](arg1);
}
export function RunAction(arg1) {
return window['go']['main']['App']['RunAction'](arg1);
}
export function ListWorklog(arg1) {
return window['go']['main']['App']['ListWorklog'](arg1);
}
export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
}
export function Search(arg1) {
return window['go']['main']['App']['Search'](arg1);
}
export function PickFile() {
return window['go']['main']['App']['PickFile']();
}
export function PickFiles() {
return window['go']['main']['App']['PickFiles']();
}
export function PickDirectory() {
return window['go']['main']['App']['PickDirectory']();
}
export function OpenFile(arg1) {
return window['go']['main']['App']['OpenFile'](arg1);
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function VerstakVersion() {
return window['go']['main']['App']['VerstakVersion']();
}
+8 -1
View File
@@ -1,15 +1,22 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { resolve } from "path";
export default defineConfig({
plugins: [svelte()],
server: {
host: "127.0.0.1",
port: 3001,
strictPort: true,
},
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
publicDir: "public",
});
+288 -2
View File
@@ -3,6 +3,7 @@ package files
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"io"
"os"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
@@ -32,15 +34,25 @@ type Record struct {
Missing bool `json:"missing"`
}
// ImportSummary describes a scanned directory before import.
type ImportSummary struct {
Files int `json:"files"`
Folders int `json:"folders"`
TotalBytes int64 `json:"totalBytes"`
IsDangerous bool `json:"isDangerous"`
DangerReason string `json:"dangerReason,omitempty"`
}
// Service provides file operations inside a vault.
type Service struct {
db *storage.DB
vaultRoot string
nodes *nodes.Repository
}
// NewService creates a file service bound to a vault.
func NewService(db *storage.DB, vaultRoot string) *Service {
return &Service{db: db, vaultRoot: vaultRoot}
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
}
// DB returns the underlying storage.
@@ -166,6 +178,280 @@ func (s *Service) Open(id string) error {
return openWithSystem(abs)
}
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
const maxPreviewSize = 5 * 1024 * 1024
// ReadText reads a file's content as text, up to maxPreviewSize.
func (s *Service) ReadText(id string) (string, error) {
rec, err := s.Get(id)
if err != nil {
return "", err
}
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
}
b, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return string(b), nil
}
// ReadBase64 reads a file and returns a data URI (base64-encoded).
func (s *Service) ReadBase64(id string) (string, error) {
rec, err := s.Get(id)
if err != nil {
return "", err
}
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
}
b, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
mime := rec.MIME
if mime == "" {
mime = "application/octet-stream"
}
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
}
// CreateEmptyFile creates a file node and an empty vault file.
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
filename = s.uniqueTitle(parentID, filename)
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
dest := filepath.Join(dir, filename)
f, err := os.Create(dest)
if err != nil {
return nil, fmt.Errorf("create file: %w", err)
}
f.Close()
relPath, _ := filepath.Rel(s.vaultRoot, dest)
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
return node, err
}
// Duplicate creates a copy of a node and its file record under the same parent.
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
original, err := s.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
parentID := ""
if original.ParentID != nil {
parentID = *original.ParentID
}
newName := s.uniqueTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
if err != nil {
return nil, err
}
if original.Type == nodes.TypeFile {
records, _ := s.ListByNode(original.ID)
if len(records) > 0 {
src := &records[0]
if src.StorageMode == "vault" {
srcPath := filepath.Join(s.vaultRoot, src.Path)
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
os.MkdirAll(dir, 0o750)
dst := filepath.Join(dir, newName)
hash, err := copyAndHash(srcPath, dst)
if err != nil {
return nil, fmt.Errorf("copy file: %w", err)
}
relPath, _ := filepath.Rel(s.vaultRoot, dst)
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
if err != nil {
return nil, err
}
} else {
// External file: create a new record pointing to the same absolute path.
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
if err != nil {
return nil, err
}
}
}
}
return node, nil
}
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
return s.importPath(nodeID, sourcePath, true)
}
// AddPathLink links sourcePath (file or directory) without copying into vault.
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
return s.importPath(nodeID, sourcePath, false)
}
// PreviewImport scans sourcePath and returns a summary without importing.
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
if !info.IsDir() {
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
}
var sum ImportSummary
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
if err != nil {
return filepath.SkipDir
}
if fi.IsDir() {
sum.Folders++
name := strings.ToLower(fi.Name())
if name == ".git" || name == "node_modules" || name == ".cache" {
sum.IsDangerous = true
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
}
return nil
}
sum.Files++
sum.TotalBytes += fi.Size()
return nil
})
if sum.Files > 1000 && !sum.IsDangerous {
sum.IsDangerous = true
sum.DangerReason = "более 1000 файлов"
}
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
sum.IsDangerous = true
sum.DangerReason = "более 1 GB"
}
return &sum, err
}
// DeleteNodeAndChildren soft-deletes a node and all descendants,
// moving vault files to trash.
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
children, _ := s.nodes.ListChildren(nodeID, false)
for i := range children {
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
return err
}
}
_ = s.deleteFileRecords(nodeID)
return s.nodes.SoftDelete(nodeID)
}
func (s *Service) deleteFileRecords(nodeID string) error {
records, err := s.ListByNode(nodeID)
if err != nil {
return err
}
for _, r := range records {
_ = s.DeleteToTrash(r.ID)
}
return nil
}
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
if !info.IsDir() {
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
if err != nil {
return nil, err
}
if copyMode {
_, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug)
} else {
_, err = s.AddExternal(node.ID, sourcePath)
}
if err != nil {
return nil, err
}
return []nodes.Node{*node}, nil
}
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
}
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
dirName = s.uniqueTitle(parentID, dirName)
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
if err != nil {
return nil, err
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return nil, err
}
var all []nodes.Node
all = append(all, *folderNode)
for _, entry := range entries {
childPath := filepath.Join(sourcePath, entry.Name())
if entry.IsDir() {
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
if err != nil {
return nil, err
}
all = append(all, children...)
} else {
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
if err != nil {
return nil, err
}
if copyMode {
_, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug)
} else {
_, err = s.AddExternal(childNode.ID, childPath)
}
if err != nil {
return nil, err
}
all = append(all, *childNode)
}
}
return all, nil
}
func (s *Service) uniqueTitle(parentID, desired string) string {
children, _ := s.nodes.ListChildren(parentID, false)
used := make(map[string]bool, len(children))
for i := range children {
used[children[i].Title] = true
}
if !used[desired] {
return desired
}
for n := 2; ; n++ {
c := fmt.Sprintf("%s (%d)", desired, n)
if !used[c] {
return c
}
}
}
// --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
+169 -4
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
@@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
db := openTestDB(t)
// Run migration 002 manually since storage.Open already applied it.
// We can verify the table exists by inserting.
filesSvc := NewService(db, t.TempDir())
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
// Create a real temp file to register.
tmpDir := t.TempDir()
@@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
func TestCopyIntoVault(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
// Source file.
srcDir := t.TempDir()
@@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
func TestListByNode(t *testing.T) {
db := openTestDB(t)
svc := NewService(db, t.TempDir())
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
f1 := filepath.Join(t.TempDir(), "a1.txt")
@@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
func TestDeleteToTrash(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
src := filepath.Join(t.TempDir(), "important.pdf")
os.WriteFile(src, []byte("important data"), 0o640)
@@ -140,6 +141,170 @@ func TestDeleteToTrash(t *testing.T) {
}
}
func TestAddPathCopySingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
src := filepath.Join(t.TempDir(), "doc.pdf")
os.WriteFile(src, []byte("file content"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, src)
if err != nil {
t.Fatalf("AddPathCopy: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
if nodes[0].Type != "file" {
t.Errorf("type = %q", nodes[0].Type)
}
// Source intact.
if _, err := os.Stat(src); err != nil {
t.Error("source should remain intact")
}
// File record created.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Errorf("file records = %d", len(records))
}
}
func TestAddPathLinkSingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
src := filepath.Join(t.TempDir(), "linked.pdf")
os.WriteFile(src, []byte("linked"), 0o640)
nodes, err := svc.AddPathLink(parent.ID, src)
if err != nil {
t.Fatalf("AddPathLink: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
// File record should have external storage mode.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Fatalf("file records = %d", len(records))
}
if records[0].StorageMode != "external" {
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
}
}
func TestAddPathCopyDirectory(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
if err != nil {
t.Fatalf("AddPathCopy dir: %v", err)
}
// Should create: folder node + file node + sub folder node + file node in sub.
if len(nodes) < 3 {
t.Errorf("expected 3+ nodes, got %d", len(nodes))
}
// Verify structure: root folder + children.
var folders, files int
for i := range nodes {
if nodes[i].Type == "folder" {
folders++
} else {
files++
}
}
if folders < 1 {
t.Error("expected at least 1 folder")
}
if files < 1 {
t.Error("expected at least 1 file")
}
}
func TestDeleteNodeAndChildren(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
// Add file record to child.
src := filepath.Join(t.TempDir(), "child.txt")
os.WriteFile(src, []byte("data"), 0o640)
svc.CopyIntoVault(child.ID, src, child.Slug)
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
t.Fatalf("DeleteNodeAndChildren: %v", err)
}
// Parent should be soft-deleted.
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
t.Error("parent should be deleted")
}
// Child should be soft-deleted.
if _, err := nodeRepo.GetActive(child.ID); err == nil {
t.Error("child should be deleted")
}
}
func TestNameConflict(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test", "")
src := filepath.Join(t.TempDir(), "conflict.pdf")
os.WriteFile(src, []byte("data"), 0o640)
// Import twice with same filename.
n1, _ := svc.AddPathCopy(parent.ID, src)
n2, _ := svc.AddPathCopy(parent.ID, src)
if n1[0].Title == n2[0].Title {
t.Error("expected unique name on conflict")
}
if n2[0].Title == "conflict.pdf" {
t.Errorf("title unchanged = %q", n2[0].Title)
}
}
func TestPreviewImportDir(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
sum, err := svc.PreviewImport(srcDir)
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
if sum.Files != 2 {
t.Errorf("files = %d, want 2", sum.Files)
}
if sum.Folders != 2 { // root + sub
t.Errorf("folders = %d, want 2", sum.Folders)
}
}
func TestGuessMIME(t *testing.T) {
cases := map[string]string{
"a.md": "text/plain",
+1 -1
View File
@@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
t.Cleanup(func() { db.Close() })
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, dir)
fileSvc := files.NewService(db, dir, nodeRepo)
svc := NewService(db, dir, nodeRepo, fileSvc)
return svc, nodeRepo, dir
}
+1 -1
View File
@@ -38,7 +38,7 @@ type Server struct {
// NewServer creates a GUI server for the given vault.
func NewServer(db *storage.DB, vaultRoot string) *Server {
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultRoot)
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
workSvc := worklog.NewService(db)