Implement milestone 6b workbench routing skeleton
This commit is contained in:
+452
-42
@@ -3,11 +3,13 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
@@ -15,12 +17,15 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
||||
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||
)
|
||||
|
||||
// App is the main application struct exposed to the Wails frontend.
|
||||
@@ -33,9 +38,12 @@ type App struct {
|
||||
plugins []plugin.Plugin
|
||||
vault *vault.Vault
|
||||
storage *storage.Storage
|
||||
files *corefiles.Service
|
||||
appSettings *appsettings.Manager
|
||||
pluginState *pluginstate.Manager
|
||||
workbench *coreworkbench.Router
|
||||
workspace *workspace.Manager
|
||||
debug bool
|
||||
}
|
||||
|
||||
// NewApp creates a new App instance.
|
||||
@@ -47,9 +55,11 @@ func NewApp(
|
||||
plugins []plugin.Plugin,
|
||||
vaultService *vault.Vault,
|
||||
storageService *storage.Storage,
|
||||
filesService *corefiles.Service,
|
||||
appSettingsMgr *appsettings.Manager,
|
||||
pluginStateMgr *pluginstate.Manager,
|
||||
workspaceMgr *workspace.Manager,
|
||||
debugEnabled bool,
|
||||
) *App {
|
||||
return &App{
|
||||
capRegistry: capReg,
|
||||
@@ -59,37 +69,119 @@ func NewApp(
|
||||
plugins: plugins,
|
||||
vault: vaultService,
|
||||
storage: storageService,
|
||||
files: filesService,
|
||||
appSettings: appSettingsMgr,
|
||||
pluginState: pluginStateMgr,
|
||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||
workspace: workspaceMgr,
|
||||
debug: debugEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences {
|
||||
if m == nil {
|
||||
return coreworkbench.Preferences{}
|
||||
}
|
||||
cfg := m.Get()
|
||||
return coreworkbench.Preferences{
|
||||
DefaultTextEditorProvider: cfg.Workbench.DefaultTextEditorProvider,
|
||||
DefaultMarkdownEditorProvider: cfg.Workbench.DefaultMarkdownEditorProvider,
|
||||
DefaultNotesMarkdownEditorProvider: cfg.Workbench.DefaultNotesMarkdownEditorProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func appSettingsWorkbenchPrefs(p coreworkbench.Preferences) appsettings.WorkbenchPreferences {
|
||||
return appsettings.WorkbenchPreferences{
|
||||
DefaultTextEditorProvider: p.DefaultTextEditorProvider,
|
||||
DefaultMarkdownEditorProvider: p.DefaultMarkdownEditorProvider,
|
||||
DefaultNotesMarkdownEditorProvider: p.DefaultNotesMarkdownEditorProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ensureWorkbench() *coreworkbench.Router {
|
||||
if a.workbench == nil {
|
||||
a.workbench = coreworkbench.NewRouter(workbenchPrefsFromSettings(a.appSettings))
|
||||
}
|
||||
return a.workbench
|
||||
}
|
||||
|
||||
// Startup is called when the app starts. Sets the Wails context for dialogs.
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
|
||||
}
|
||||
|
||||
func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {
|
||||
for i := range a.plugins {
|
||||
if a.plugins[i].Manifest.ID == pluginID {
|
||||
return &a.plugins[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q not found", pluginID)
|
||||
}
|
||||
|
||||
func (a *App) requirePluginAccess(pluginID, permission string) (*plugin.Plugin, error) {
|
||||
p, err := a.findPlugin(pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
|
||||
return nil, fmt.Errorf("plugin %q is not enabled and loaded: status=%s enabled=%v", pluginID, p.Status, p.Enabled)
|
||||
}
|
||||
if permission != "" && !hasString(p.Manifest.Permissions, permission) {
|
||||
return nil, fmt.Errorf("plugin %q lacks required permission %q", pluginID, permission)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *App) requirePluginCapabilityAccess(pluginID, capabilityName string) (*plugin.Plugin, error) {
|
||||
p, err := a.requirePluginAccess(pluginID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasString(p.Manifest.Requires, capabilityName) && !hasString(p.Manifest.OptionalRequires, capabilityName) {
|
||||
return nil, fmt.Errorf("plugin %q does not declare capability dependency %q", pluginID, capabilityName)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func hasString(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Plugin Manager API ─────────────────────────────────────
|
||||
|
||||
// GetPlugins returns all discovered plugins.
|
||||
func (a *App) GetPlugins() []plugin.Plugin {
|
||||
log.Printf("[api] GetPlugins: returning %d plugins", len(a.plugins))
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetPlugins: returning %d plugins", len(a.plugins))
|
||||
for i, p := range a.plugins {
|
||||
debug.Logf("[api] plugin[%d]: id=%s status=%s enabled=%v root=%s", i, p.Manifest.ID, p.Status, p.Enabled, p.RootPath)
|
||||
}
|
||||
}
|
||||
return a.plugins
|
||||
}
|
||||
|
||||
// GetCapabilities returns all registered capabilities.
|
||||
func (a *App) GetCapabilities() []capability.Entry {
|
||||
entries := a.capRegistry.List()
|
||||
log.Printf("[api] GetCapabilities: returning %d entries", len(entries))
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetCapabilities: returning %d entries", len(entries))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// GetPermissions returns all known permissions.
|
||||
func (a *App) GetPermissions() []permissions.Entry {
|
||||
entries := a.permRegistry.List()
|
||||
log.Printf("[api] GetPermissions: returning %d entries", len(entries))
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetPermissions: returning %d entries", len(entries))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -132,12 +224,29 @@ type FlatCommand struct {
|
||||
Handler string `json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
type FlatOpenProviderSupport struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime []string `json:"mime,omitempty"`
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
Contexts []string `json:"contexts,omitempty"`
|
||||
}
|
||||
|
||||
type FlatOpenProvider struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Component string `json:"component"`
|
||||
Supports []FlatOpenProviderSupport `json:"supports"`
|
||||
}
|
||||
|
||||
// ContributionSummary aggregates all contribution types for the frontend.
|
||||
type ContributionSummary struct {
|
||||
Views []FlatView `json:"views"`
|
||||
Commands []FlatCommand `json:"commands"`
|
||||
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
||||
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
||||
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
||||
}
|
||||
|
||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||
@@ -149,6 +258,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
regCmds := r.Commands()
|
||||
regPanels := r.SettingsPanels()
|
||||
regSidebar := r.SidebarItems()
|
||||
regOpenProviders := r.OpenProviders()
|
||||
|
||||
views := make([]FlatView, len(regViews))
|
||||
for i, v := range regViews {
|
||||
@@ -166,46 +276,43 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
for i, v := range regSidebar {
|
||||
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar}
|
||||
openProviders := make([]FlatOpenProvider, len(regOpenProviders))
|
||||
for i, v := range regOpenProviders {
|
||||
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
|
||||
for j, s := range v.Item.Supports {
|
||||
supports[j] = FlatOpenProviderSupport{Kind: s.Kind, Mime: s.Mime, Extensions: s.Extensions, Contexts: s.Contexts}
|
||||
}
|
||||
openProviders[i] = FlatOpenProvider{
|
||||
PluginID: v.PluginID,
|
||||
ID: v.Item.ID,
|
||||
Title: v.Item.Title,
|
||||
Priority: v.Item.Priority,
|
||||
Component: v.Item.Component,
|
||||
Supports: supports,
|
||||
}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders}
|
||||
}
|
||||
|
||||
// GetContributions returns all registered contributions flattened for the frontend.
|
||||
func (a *App) GetContributions() ContributionSummary {
|
||||
if a.contribRegistry == nil {
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetContributions: contribRegistry is nil")
|
||||
}
|
||||
return ContributionSummary{}
|
||||
}
|
||||
return buildContributionSummary(a.contribRegistry)
|
||||
}
|
||||
|
||||
// expandPath resolves "~" to the user's home directory.
|
||||
func expandPath(path string) string {
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Printf("[api] expandPath: cannot get home dir: %v", err)
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
summary := buildContributionSummary(a.contribRegistry)
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
|
||||
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
|
||||
}
|
||||
return path
|
||||
return summary
|
||||
}
|
||||
|
||||
// ReloadPlugins re-discovers plugins from disk and returns a summary.
|
||||
func (a *App) ReloadPlugins() (int, string) {
|
||||
// Resolve plugin directories relative to the binary location
|
||||
binDir := filepath.Dir(os.Args[0])
|
||||
pluginDir := filepath.Join(binDir, "plugins")
|
||||
|
||||
discoveryDirs := []string{
|
||||
"~/.config/verstak/plugins",
|
||||
pluginDir,
|
||||
}
|
||||
|
||||
// Expand tilde in all paths
|
||||
for i, d := range discoveryDirs {
|
||||
discoveryDirs[i] = expandPath(d)
|
||||
}
|
||||
|
||||
discoveryDirs := plugin.DefaultDiscoveryDirs()
|
||||
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
|
||||
|
||||
// Unregister all non-core capabilities
|
||||
@@ -218,6 +325,8 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||
"verstak/core/contribution-registry/v1",
|
||||
"verstak/core/permissions/v1",
|
||||
"verstak/core/events/v1",
|
||||
"verstak/core/files/v1",
|
||||
"verstak/core/workbench/v1",
|
||||
}
|
||||
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
|
||||
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
|
||||
@@ -335,6 +444,10 @@ func (a *App) GetVaultStatus() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
if a.debug {
|
||||
debug.Logf("[api] GetVaultStatus: status=%s path=%s vaultId=%s", status, path, vaultID)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"status": status,
|
||||
"path": path,
|
||||
@@ -370,20 +483,26 @@ func (a *App) CloseVault() error {
|
||||
// ─── Storage API ────────────────────────────────────────────
|
||||
|
||||
// ReadPluginSettings returns all settings for a plugin.
|
||||
func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} {
|
||||
func (a *App) ReadPluginSettings(pluginID string) (map[string]interface{}, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
return make(map[string]interface{}), err.Error()
|
||||
}
|
||||
if a.storage == nil {
|
||||
return make(map[string]interface{})
|
||||
return make(map[string]interface{}), "storage not initialized"
|
||||
}
|
||||
data, err := a.storage.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
|
||||
return make(map[string]interface{})
|
||||
return make(map[string]interface{}), err.Error()
|
||||
}
|
||||
return data
|
||||
return data, ""
|
||||
}
|
||||
|
||||
// WritePluginSettings writes all settings for a plugin.
|
||||
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
@@ -396,6 +515,10 @@ func (a *App) WritePluginSettings(pluginID string, data map[string]interface{})
|
||||
|
||||
// ReadPluginSetting returns a single setting value.
|
||||
func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
|
||||
return nil
|
||||
}
|
||||
if a.storage == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -409,6 +532,9 @@ func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
||||
|
||||
// WritePluginSetting writes a single setting value.
|
||||
func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
@@ -421,6 +547,10 @@ func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string
|
||||
|
||||
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||
func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
if a.storage == nil {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
@@ -434,6 +564,9 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||
|
||||
// WritePluginDataJSON writes a named JSON data file for a plugin.
|
||||
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.storage == nil {
|
||||
return "storage not initialized"
|
||||
}
|
||||
@@ -444,6 +577,278 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListVaultFiles lists a vault-relative directory for a plugin with files.read.
|
||||
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return nil, "files service not initialized"
|
||||
}
|
||||
entries, err := a.files.ListVaultFiles(relativeDir)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return entries, ""
|
||||
}
|
||||
|
||||
// GetVaultFileMetadata returns metadata for a vault-relative path for a plugin with files.read.
|
||||
func (a *App) GetVaultFileMetadata(pluginID, relativePath string) (corefiles.FileMetadata, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||
return corefiles.FileMetadata{}, err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return corefiles.FileMetadata{}, "files service not initialized"
|
||||
}
|
||||
meta, err := a.files.GetVaultFileMetadata(relativePath)
|
||||
if err != nil {
|
||||
return corefiles.FileMetadata{}, err.Error()
|
||||
}
|
||||
return meta, ""
|
||||
}
|
||||
|
||||
// ReadVaultTextFile reads a UTF-8 text file for a plugin with files.read.
|
||||
func (a *App) ReadVaultTextFile(pluginID, relativePath string) (string, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return "", "files service not initialized"
|
||||
}
|
||||
text, err := a.files.ReadVaultTextFile(relativePath)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
|
||||
// WriteVaultTextFile atomically writes a UTF-8 text file for a plugin with files.write.
|
||||
func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, options corefiles.WriteOptions) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return "files service not initialized"
|
||||
}
|
||||
if err := a.files.WriteVaultTextFile(relativePath, content, options); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateVaultFolder creates a vault-relative folder for a plugin with files.write.
|
||||
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return "files service not initialized"
|
||||
}
|
||||
if err := a.files.CreateVaultFolder(relativePath); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MoveVaultPath moves a vault-relative file or folder for a plugin with files.write.
|
||||
func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath string, options corefiles.MoveOptions) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return "files service not initialized"
|
||||
}
|
||||
if err := a.files.MoveVaultPath(fromRelativePath, toRelativePath, options); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrashVaultPath moves a vault-relative file or folder to internal trash for a plugin with files.delete.
|
||||
func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResult, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil {
|
||||
return corefiles.TrashResult{}, err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return corefiles.TrashResult{}, "files service not initialized"
|
||||
}
|
||||
result, err := a.files.TrashVaultPath(relativePath)
|
||||
if err != nil {
|
||||
return corefiles.TrashResult{}, err.Error()
|
||||
}
|
||||
return result, ""
|
||||
}
|
||||
|
||||
func (a *App) activeOpenProviders() []contribution.ContributionOpenProvider {
|
||||
if a.contribRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
providers := a.contribRegistry.OpenProviders()
|
||||
active := make([]contribution.ContributionOpenProvider, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
p, err := a.findPlugin(provider.PluginID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
|
||||
continue
|
||||
}
|
||||
active = append(active, provider)
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func decodeOpenResourceRequest(raw map[string]interface{}) (coreworkbench.OpenResourceRequest, error) {
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return coreworkbench.OpenResourceRequest{}, err
|
||||
}
|
||||
var request coreworkbench.OpenResourceRequest
|
||||
if err := json.Unmarshal(data, &request); err != nil {
|
||||
return coreworkbench.OpenResourceRequest{}, err
|
||||
}
|
||||
if request.Kind == "" {
|
||||
return request, fmt.Errorf("resource kind is empty")
|
||||
}
|
||||
if request.Path == "" {
|
||||
return request, fmt.Errorf("resource path is empty")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "workbench.open"); err != nil {
|
||||
return coreworkbench.OpenResourceResult{}, err.Error()
|
||||
}
|
||||
request, err := decodeOpenResourceRequest(rawRequest)
|
||||
if err != nil {
|
||||
return coreworkbench.OpenResourceResult{}, err.Error()
|
||||
}
|
||||
if request.Context.SourcePluginID == "" {
|
||||
request.Context.SourcePluginID = pluginID
|
||||
}
|
||||
result, err := a.ensureWorkbench().OpenResource(request, a.activeOpenProviders())
|
||||
if err != nil {
|
||||
return coreworkbench.OpenResourceResult{}, err.Error()
|
||||
}
|
||||
return result, ""
|
||||
}
|
||||
|
||||
func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
|
||||
if rawRequest == nil {
|
||||
rawRequest = map[string]interface{}{}
|
||||
}
|
||||
rawRequest["mode"] = "edit"
|
||||
return a.OpenWorkbenchResource(pluginID, rawRequest)
|
||||
}
|
||||
|
||||
func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource {
|
||||
return a.ensureWorkbench().OpenedResources()
|
||||
}
|
||||
|
||||
func (a *App) GetWorkbenchPreferences() coreworkbench.Preferences {
|
||||
return a.ensureWorkbench().Preferences()
|
||||
}
|
||||
|
||||
func (a *App) UpdateWorkbenchPreferences(preferences coreworkbench.Preferences) string {
|
||||
a.ensureWorkbench().SetPreferences(preferences)
|
||||
if a.appSettings == nil {
|
||||
return ""
|
||||
}
|
||||
if err := a.appSettings.Update(&appsettings.Config{Workbench: appSettingsWorkbenchPrefs(preferences)}); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListPluginCapabilities returns the current capability registry for an enabled plugin.
|
||||
func (a *App) ListPluginCapabilities(pluginID string) ([]capability.Entry, string) {
|
||||
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
if a.capRegistry == nil {
|
||||
return nil, "capability registry not initialized"
|
||||
}
|
||||
return a.capRegistry.List(), ""
|
||||
}
|
||||
|
||||
// GetPluginCapability returns a single capability lookup for an enabled plugin.
|
||||
func (a *App) GetPluginCapability(pluginID, capabilityName string) (map[string]interface{}, string) {
|
||||
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
|
||||
return map[string]interface{}{"available": false}, err.Error()
|
||||
}
|
||||
if a.capRegistry == nil {
|
||||
return map[string]interface{}{"available": false}, "capability registry not initialized"
|
||||
}
|
||||
entry := a.capRegistry.Get(capabilityName)
|
||||
if entry == nil {
|
||||
return map[string]interface{}{"available": false, "name": capabilityName}, ""
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"available": true,
|
||||
"name": entry.Name,
|
||||
"pluginId": entry.PluginID,
|
||||
"status": entry.Status,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// ExecutePluginCommand validates that a command is declared by the plugin.
|
||||
// Actual handler execution is intentionally deferred until sidecar/RPC exists.
|
||||
func (a *App) ExecutePluginCommand(pluginID, commandID string, args map[string]interface{}) (map[string]interface{}, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "commands.register"); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
if a.contribRegistry == nil {
|
||||
return nil, "contribution registry not initialized"
|
||||
}
|
||||
for _, command := range a.contribRegistry.Commands() {
|
||||
if command.PluginID == pluginID && command.Item.ID == commandID {
|
||||
return map[string]interface{}{
|
||||
"status": "declared",
|
||||
"pluginId": pluginID,
|
||||
"commandId": commandID,
|
||||
"handler": command.Item.Handler,
|
||||
"args": args,
|
||||
}, ""
|
||||
}
|
||||
}
|
||||
return nil, fmt.Sprintf("command %q is not declared by plugin %q", commandID, pluginID)
|
||||
}
|
||||
|
||||
// PublishPluginEvent validates publish permission and emits to the in-process bus.
|
||||
func (a *App) PublishPluginEvent(pluginID, eventName string, payload map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "events.publish"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if eventName == "" {
|
||||
return "event name is empty"
|
||||
}
|
||||
if payload == nil {
|
||||
payload = make(map[string]interface{})
|
||||
}
|
||||
payload["pluginId"] = pluginID
|
||||
if a.eventBus != nil {
|
||||
a.eventBus.Publish(events.Event{
|
||||
Name: eventName,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubscribePluginEvent validates subscribe permission for a bundled frontend plugin.
|
||||
// Actual bundled event dispatch is handled by the frontend plugin host event bus.
|
||||
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if eventName == "" {
|
||||
return "event name is empty"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── App Settings API ──────────────────────────────────────
|
||||
|
||||
// GetAppSettings returns the current app settings.
|
||||
@@ -510,13 +915,11 @@ func (a *App) SetCurrentVault(path string) string {
|
||||
log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
|
||||
}
|
||||
}
|
||||
// Load workspace for the vault
|
||||
if a.workspace != nil {
|
||||
// Replace workspace manager with one pointing to the new vault
|
||||
a.workspace = workspace.NewManager(vaultPath)
|
||||
if err := a.workspace.Load(); err != nil {
|
||||
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
|
||||
}
|
||||
// Load workspace for the vault. This also handles first-run startup,
|
||||
// where no workspace manager exists until a vault is selected.
|
||||
a.workspace = workspace.NewManager(vaultPath)
|
||||
if err := a.workspace.Load(); err != nil {
|
||||
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
|
||||
}
|
||||
// Register vault capability
|
||||
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
||||
@@ -679,6 +1082,13 @@ func (a *App) RecordDesiredPlugin(pluginID, version, source string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// WriteFrontendLog writes a frontend debug message to the backend debug log.
|
||||
func (a *App) WriteFrontendLog(component, message string) {
|
||||
if a.debug {
|
||||
debug.Logf("[frontend][%s] %s", component, message)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dialog API ─────────────────────────────────────────────
|
||||
|
||||
// SelectDirectory opens a native directory picker dialog.
|
||||
|
||||
@@ -6,7 +6,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
)
|
||||
|
||||
// newTestApp creates an App with a mocked plugin list for testing.
|
||||
@@ -45,6 +53,31 @@ func newTestApp(tmpRoot string) *App {
|
||||
}
|
||||
}
|
||||
|
||||
func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
|
||||
t.Helper()
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
return &App{
|
||||
files: corefiles.NewService(v),
|
||||
vault: v,
|
||||
plugins: []plugin.Plugin{
|
||||
{
|
||||
Manifest: plugin.Manifest{
|
||||
ID: "files.plugin",
|
||||
Name: "Files Plugin",
|
||||
Version: "1.0.0",
|
||||
Provides: []string{"files/plugin/v1"},
|
||||
Permissions: perms,
|
||||
},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}, v.GetVaultPath()
|
||||
}
|
||||
|
||||
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
|
||||
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
|
||||
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
|
||||
@@ -255,3 +288,443 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
|
||||
t.Errorf("error should mention 'failed to read', got: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
|
||||
|
||||
if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" {
|
||||
t.Fatalf("CreateVaultFolder: %s", errStr)
|
||||
}
|
||||
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
|
||||
t.Fatalf("WriteVaultTextFile: %s", errStr)
|
||||
}
|
||||
|
||||
text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/one.txt")
|
||||
if errStr != "" {
|
||||
t.Fatalf("ReadVaultTextFile: %s", errStr)
|
||||
}
|
||||
if text != "hello" {
|
||||
t.Fatalf("text = %q", text)
|
||||
}
|
||||
|
||||
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
|
||||
if errStr != "" {
|
||||
t.Fatalf("ListVaultFiles: %s", errStr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].RelativePath != "Docs/one.txt" {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
|
||||
meta, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt")
|
||||
if errStr != "" {
|
||||
t.Fatalf("GetVaultFileMetadata: %s", errStr)
|
||||
}
|
||||
if meta.Type != corefiles.FileTypeFile || !meta.IsText {
|
||||
t.Fatalf("metadata = %+v", meta)
|
||||
}
|
||||
|
||||
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
|
||||
t.Fatalf("MoveVaultPath: %s", errStr)
|
||||
}
|
||||
trash, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt")
|
||||
if errStr != "" {
|
||||
t.Fatalf("TrashVaultPath: %s", errStr)
|
||||
}
|
||||
if trash.OriginalPath != "Docs/two.txt" || trash.TrashID == "" {
|
||||
t.Fatalf("trash result = %+v", trash)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); err != nil {
|
||||
t.Fatalf("trash path missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgePermissions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
perms []string
|
||||
call func(*App) string
|
||||
wantPhrase string
|
||||
}{
|
||||
{
|
||||
name: "list requires read",
|
||||
perms: []string{"files.write", "files.delete"},
|
||||
call: func(app *App) string { _, errStr := app.ListVaultFiles("files.plugin", ""); return errStr },
|
||||
wantPhrase: "files.read",
|
||||
},
|
||||
{
|
||||
name: "metadata requires read",
|
||||
perms: []string{"files.write", "files.delete"},
|
||||
call: func(app *App) string { _, errStr := app.GetVaultFileMetadata("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.read",
|
||||
},
|
||||
{
|
||||
name: "read requires read",
|
||||
perms: []string{"files.write", "files.delete"},
|
||||
call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.read",
|
||||
},
|
||||
{
|
||||
name: "write requires write",
|
||||
perms: []string{"files.read", "files.delete"},
|
||||
call: func(app *App) string {
|
||||
return app.WriteVaultTextFile("files.plugin", "one.txt", "x", corefiles.WriteOptions{CreateIfMissing: true})
|
||||
},
|
||||
wantPhrase: "files.write",
|
||||
},
|
||||
{
|
||||
name: "create folder requires write",
|
||||
perms: []string{"files.read", "files.delete"},
|
||||
call: func(app *App) string { return app.CreateVaultFolder("files.plugin", "Folder") },
|
||||
wantPhrase: "files.write",
|
||||
},
|
||||
{
|
||||
name: "move requires write",
|
||||
perms: []string{"files.read", "files.delete"},
|
||||
call: func(app *App) string {
|
||||
return app.MoveVaultPath("files.plugin", "one.txt", "two.txt", corefiles.MoveOptions{})
|
||||
},
|
||||
wantPhrase: "files.write",
|
||||
},
|
||||
{
|
||||
name: "trash requires delete",
|
||||
perms: []string{"files.read", "files.write"},
|
||||
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.delete",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app, _ := newFilesTestApp(t, tc.perms)
|
||||
errStr := tc.call(app)
|
||||
if errStr == "" {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
if !strings.Contains(errStr, tc.wantPhrase) {
|
||||
t.Fatalf("error = %q, want %q", errStr, tc.wantPhrase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesBridgeRequiresLoadedPluginAndOpenVault(t *testing.T) {
|
||||
app, _ := newFilesTestApp(t, []string{"files.read"})
|
||||
app.plugins[0].Enabled = false
|
||||
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
|
||||
t.Fatalf("disabled plugin error = %q", errStr)
|
||||
}
|
||||
|
||||
app, _ = newFilesTestApp(t, []string{"files.read"})
|
||||
app.plugins[0].Status = plugin.StatusFailed
|
||||
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
|
||||
t.Fatalf("failed plugin error = %q", errStr)
|
||||
}
|
||||
|
||||
app, _ = newFilesTestApp(t, []string{"files.read"})
|
||||
app.plugins[0].Status = plugin.StatusDegraded
|
||||
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr != "" {
|
||||
t.Fatalf("degraded plugin should be allowed, got %q", errStr)
|
||||
}
|
||||
|
||||
app, _ = newFilesTestApp(t, []string{"files.read"})
|
||||
if _, errStr := app.ListVaultFiles("missing.plugin", ""); errStr == "" || !strings.Contains(errStr, "not found") {
|
||||
t.Fatalf("missing plugin error = %q", errStr)
|
||||
}
|
||||
|
||||
app, _ = newFilesTestApp(t, []string{"files.read"})
|
||||
app.vault.CloseVault()
|
||||
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "vault-not-open") {
|
||||
t.Fatalf("closed vault error = %q", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
vaultParent := filepath.Join(tmpDir, "vault-parent")
|
||||
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bus := events.NewBus()
|
||||
vaultService := vault.NewVault(bus)
|
||||
if err := vaultService.CreateVault(vaultParent); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
vaultService.CloseVault()
|
||||
|
||||
settings := appsettings.NewManager(filepath.Join(tmpDir, "config.json"))
|
||||
if err := settings.Load(); err != nil {
|
||||
t.Fatalf("settings Load: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
capRegistry: capability.NewRegistry(),
|
||||
vault: vaultService,
|
||||
appSettings: settings,
|
||||
workspace: nil,
|
||||
}
|
||||
|
||||
if errStr := app.SetCurrentVault(vaultParent); errStr != "" {
|
||||
t.Fatalf("SetCurrentVault: %s", errStr)
|
||||
}
|
||||
|
||||
tree := app.GetWorkspaceTree()
|
||||
if tree["status"] == "not initialized" {
|
||||
t.Fatal("workspace should be initialized after SetCurrentVault")
|
||||
}
|
||||
nodes, ok := tree["nodes"].([]workspace.WorkspaceNode)
|
||||
if !ok {
|
||||
t.Fatalf("workspace nodes type: got %T", tree["nodes"])
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
t.Fatal("workspace nodes should not be empty")
|
||||
}
|
||||
if !app.capRegistry.Has("verstak/core/workspace/v1") {
|
||||
t.Fatal("workspace capability should be registered after SetCurrentVault")
|
||||
}
|
||||
}
|
||||
|
||||
func newBridgeTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
vaultParent := filepath.Join(tmpDir, "vault-parent")
|
||||
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bus := events.NewBus()
|
||||
vaultService := vault.NewVault(bus)
|
||||
if err := vaultService.CreateVault(vaultParent); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
|
||||
capReg := capability.NewRegistry()
|
||||
if err := capReg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := capReg.Register("bridge.plugin", []string{"bridge/cap/v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
contribReg := contribution.NewRegistry()
|
||||
contribReg.Register("bridge.plugin", &plugin.Contributions{
|
||||
Commands: []plugin.ContributionCommand{
|
||||
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
|
||||
},
|
||||
OpenProviders: []plugin.ContributionOpenProvider{
|
||||
{
|
||||
ID: "bridge.markdown",
|
||||
Title: "Bridge Markdown",
|
||||
Priority: 100,
|
||||
Component: "BridgeMarkdown",
|
||||
Supports: []plugin.OpenProviderSupport{
|
||||
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return &App{
|
||||
capRegistry: capReg,
|
||||
contribRegistry: contribReg,
|
||||
eventBus: bus,
|
||||
vault: vaultService,
|
||||
storage: storage.New(vaultService),
|
||||
plugins: []plugin.Plugin{
|
||||
{
|
||||
Manifest: plugin.Manifest{
|
||||
ID: "bridge.plugin",
|
||||
Name: "Bridge Plugin",
|
||||
Version: "1.0.0",
|
||||
Provides: []string{"bridge/cap/v1"},
|
||||
Requires: []string{"verstak/core/capability-registry/v1"},
|
||||
Permissions: []string{"storage.namespace", "commands.register", "events.publish", "events.subscribe", "workbench.open"},
|
||||
},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Manifest: plugin.Manifest{
|
||||
ID: "no.storage",
|
||||
Name: "No Storage",
|
||||
Version: "1.0.0",
|
||||
Provides: []string{"no/storage/v1"},
|
||||
Permissions: []string{"events.publish"},
|
||||
},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Manifest: plugin.Manifest{
|
||||
ID: "disabled.plugin",
|
||||
Name: "Disabled",
|
||||
Version: "1.0.0",
|
||||
Provides: []string{"disabled/cap/v1"},
|
||||
Permissions: []string{"storage.namespace"},
|
||||
},
|
||||
Status: plugin.StatusDisabled,
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributionSummaryIncludesOpenProviders(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
summary := app.GetContributions()
|
||||
if len(summary.OpenProviders) != 1 {
|
||||
t.Fatalf("OpenProviders count = %d, want 1", len(summary.OpenProviders))
|
||||
}
|
||||
provider := summary.OpenProviders[0]
|
||||
if provider.PluginID != "bridge.plugin" || provider.ID != "bridge.markdown" || provider.Component != "BridgeMarkdown" {
|
||||
t.Fatalf("provider = %+v", provider)
|
||||
}
|
||||
if len(provider.Supports) != 1 || provider.Supports[0].Contexts[1] != "notes-markdown" {
|
||||
t.Fatalf("supports = %+v", provider.Supports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{
|
||||
OpenProviders: []plugin.ContributionOpenProvider{
|
||||
{
|
||||
ID: "disabled.markdown",
|
||||
Title: "Disabled Markdown",
|
||||
Priority: 1000,
|
||||
Component: "DisabledMarkdown",
|
||||
Supports: []plugin.OpenProviderSupport{
|
||||
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
|
||||
"kind": "vault-file",
|
||||
"path": "Notes/Overview.md",
|
||||
"extension": ".md",
|
||||
"context": map[string]interface{}{
|
||||
"sourceView": "notes",
|
||||
"isInsideNotesFolder": true,
|
||||
"notesMode": true,
|
||||
},
|
||||
})
|
||||
if errStr != "" {
|
||||
t.Fatalf("OpenWorkbenchResource: %s", errStr)
|
||||
}
|
||||
if result.ProviderID != "bridge.markdown" || result.ProviderComponent != "BridgeMarkdown" || result.Request.Mode != "view" {
|
||||
t.Fatalf("open result = %+v", result)
|
||||
}
|
||||
|
||||
editResult, errStr := app.EditWorkbenchResource("bridge.plugin", map[string]interface{}{
|
||||
"kind": "vault-file",
|
||||
"path": "Notes/Overview.md",
|
||||
"extension": ".md",
|
||||
"context": map[string]interface{}{
|
||||
"sourceView": "notes",
|
||||
"isInsideNotesFolder": true,
|
||||
"notesMode": true,
|
||||
},
|
||||
})
|
||||
if errStr != "" {
|
||||
t.Fatalf("EditWorkbenchResource: %s", errStr)
|
||||
}
|
||||
if editResult.Request.Mode != "edit" {
|
||||
t.Fatalf("edit mode = %q", editResult.Request.Mode)
|
||||
}
|
||||
|
||||
opened := app.GetWorkbenchOpenedResources()
|
||||
if len(opened) != 2 {
|
||||
t.Fatalf("opened resources = %+v", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbenchOpenResourceReturnsNoProviderFallback(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
|
||||
"kind": "vault-file",
|
||||
"path": "Images/logo.png",
|
||||
})
|
||||
if errStr != "" {
|
||||
t.Fatalf("OpenWorkbenchResource: %s", errStr)
|
||||
}
|
||||
if result.Status != "no-provider" || result.Request.Path != "Images/logo.png" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
_, errStr := app.OpenWorkbenchResource("no.storage", map[string]interface{}{
|
||||
"kind": "vault-file",
|
||||
"path": "Docs/readme.md",
|
||||
})
|
||||
if !strings.Contains(errStr, "workbench.open") {
|
||||
t.Fatalf("err = %q, want workbench.open permission error", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
if errStr := app.WritePluginSettings("bridge.plugin", map[string]interface{}{"savedText": "hello"}); errStr != "" {
|
||||
t.Fatalf("WritePluginSettings: %s", errStr)
|
||||
}
|
||||
settings, errStr := app.ReadPluginSettings("bridge.plugin")
|
||||
if errStr != "" {
|
||||
t.Fatalf("ReadPluginSettings: %s", errStr)
|
||||
}
|
||||
if settings["savedText"] != "hello" {
|
||||
t.Fatalf("savedText = %v, want hello", settings["savedText"])
|
||||
}
|
||||
|
||||
if _, errStr := app.ReadPluginSettings("missing.plugin"); errStr == "" {
|
||||
t.Fatal("expected error for missing plugin")
|
||||
}
|
||||
if _, errStr := app.ReadPluginSettings("disabled.plugin"); errStr == "" {
|
||||
t.Fatal("expected error for disabled plugin")
|
||||
}
|
||||
if _, errStr := app.ReadPluginSettings("no.storage"); errStr == "" {
|
||||
t.Fatal("expected error for plugin without storage.namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
capInfo, errStr := app.GetPluginCapability("bridge.plugin", "bridge/cap/v1")
|
||||
if errStr != "" {
|
||||
t.Fatalf("GetPluginCapability: %s", errStr)
|
||||
}
|
||||
if capInfo["available"] != true {
|
||||
t.Fatalf("capability should be available: %#v", capInfo)
|
||||
}
|
||||
if _, errStr := app.GetPluginCapability("no.storage", "bridge/cap/v1"); errStr == "" {
|
||||
t.Fatal("expected capability dependency error")
|
||||
}
|
||||
|
||||
commandResult, errStr := app.ExecutePluginCommand("bridge.plugin", "bridge.command", map[string]interface{}{"value": "x"})
|
||||
if errStr != "" {
|
||||
t.Fatalf("ExecutePluginCommand: %s", errStr)
|
||||
}
|
||||
if commandResult["status"] != "declared" {
|
||||
t.Fatalf("command status = %v, want declared", commandResult["status"])
|
||||
}
|
||||
|
||||
if errStr := app.PublishPluginEvent("bridge.plugin", "bridge.event", map[string]interface{}{"ok": true}); errStr != "" {
|
||||
t.Fatalf("PublishPluginEvent: %s", errStr)
|
||||
}
|
||||
if errStr := app.SubscribePluginEvent("bridge.plugin", "bridge.event"); errStr != "" {
|
||||
t.Fatalf("SubscribePluginEvent: %s", errStr)
|
||||
}
|
||||
if errStr := app.SubscribePluginEvent("no.storage", "bridge.event"); errStr == "" {
|
||||
t.Fatal("expected subscribe permission error")
|
||||
}
|
||||
if _, errStr := app.ExecutePluginCommand("no.storage", "bridge.command", nil); errStr == "" {
|
||||
t.Fatal("expected command permission/ownership error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,21 @@ import (
|
||||
|
||||
// Config represents the application settings stored in ~/.config/verstak/config.json.
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CurrentVaultPath string `json:"currentVaultPath"`
|
||||
RecentVaults []string `json:"recentVaults"`
|
||||
Theme string `json:"theme"`
|
||||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
WindowState *WindowState `json:"windowState,omitempty"`
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
CurrentVaultPath string `json:"currentVaultPath"`
|
||||
RecentVaults []string `json:"recentVaults"`
|
||||
Theme string `json:"theme"`
|
||||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||
WindowState *WindowState `json:"windowState,omitempty"`
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
}
|
||||
|
||||
type WorkbenchPreferences struct {
|
||||
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
|
||||
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
|
||||
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
||||
}
|
||||
|
||||
// WindowState stores the last window position and size.
|
||||
@@ -156,6 +163,15 @@ func (m *Manager) Update(patch *Config) error {
|
||||
if patch.WindowState != nil {
|
||||
m.config.WindowState = patch.WindowState
|
||||
}
|
||||
if patch.Workbench.DefaultTextEditorProvider != "" {
|
||||
m.config.Workbench.DefaultTextEditorProvider = patch.Workbench.DefaultTextEditorProvider
|
||||
}
|
||||
if patch.Workbench.DefaultMarkdownEditorProvider != "" {
|
||||
m.config.Workbench.DefaultMarkdownEditorProvider = patch.Workbench.DefaultMarkdownEditorProvider
|
||||
}
|
||||
if patch.Workbench.DefaultNotesMarkdownEditorProvider != "" {
|
||||
m.config.Workbench.DefaultNotesMarkdownEditorProvider = patch.Workbench.DefaultNotesMarkdownEditorProvider
|
||||
}
|
||||
m.config.DevMode = patch.DevMode
|
||||
|
||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
@@ -201,6 +217,7 @@ func defaultConfig() *Config {
|
||||
Theme: "dark",
|
||||
DevMode: false,
|
||||
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
|
||||
Workbench: WorkbenchPreferences{},
|
||||
WindowState: &WindowState{Width: 1200, Height: 800},
|
||||
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
@@ -216,6 +233,7 @@ func copyConfig(c *Config) *Config {
|
||||
Theme: c.Theme,
|
||||
DevMode: c.DevMode,
|
||||
UserPluginsDir: c.UserPluginsDir,
|
||||
Workbench: c.Workbench,
|
||||
LastOpenedAt: c.LastOpenedAt,
|
||||
}
|
||||
if c.WindowState != nil {
|
||||
|
||||
@@ -124,6 +124,37 @@ func TestUpdate_Patch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkbenchPreferences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
m := NewManager(path)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := m.Update(&Config{
|
||||
Workbench: WorkbenchPreferences{
|
||||
DefaultTextEditorProvider: "editor.text",
|
||||
DefaultMarkdownEditorProvider: "editor.markdown",
|
||||
DefaultNotesMarkdownEditorProvider: "editor.notes",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
reloaded := NewManager(path)
|
||||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := reloaded.Get()
|
||||
if cfg.Workbench.DefaultTextEditorProvider != "editor.text" ||
|
||||
cfg.Workbench.DefaultMarkdownEditorProvider != "editor.markdown" ||
|
||||
cfg.Workbench.DefaultNotesMarkdownEditorProvider != "editor.notes" {
|
||||
t.Fatalf("workbench preferences = %+v", cfg.Workbench)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSettings_NotInsideVault(t *testing.T) {
|
||||
// App settings path should be under ~/.config/verstak/, not inside vault
|
||||
path := DefaultConfigPath()
|
||||
|
||||
@@ -22,6 +22,7 @@ type Registry struct {
|
||||
searchProviders []ContributionSearchProvider
|
||||
activityProviders []ContributionActivityProvider
|
||||
statusBarItems []ContributionStatusBarItem
|
||||
openProviders []ContributionOpenProvider
|
||||
}
|
||||
|
||||
// ContributionPointType defines the type of contribution point.
|
||||
@@ -38,6 +39,7 @@ const (
|
||||
PointSearchProviders ContributionPointType = "searchProviders"
|
||||
PointActivity ContributionPointType = "activityProviders"
|
||||
PointStatusBar ContributionPointType = "statusBarItems"
|
||||
PointOpenProviders ContributionPointType = "openProviders"
|
||||
)
|
||||
|
||||
// ListByPoint returns all contributions for a given point type.
|
||||
@@ -87,6 +89,10 @@ func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
|
||||
for _, v := range r.statusBarItems {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointOpenProviders:
|
||||
for _, v := range r.openProviders {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -136,6 +142,11 @@ type ContributionStatusBarItem struct {
|
||||
Item plugin.ContributionStatusBarItem `json:"item"`
|
||||
}
|
||||
|
||||
type ContributionOpenProvider struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
Item plugin.ContributionOpenProvider `json:"item"`
|
||||
}
|
||||
|
||||
// NewRegistry creates a new contribution registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
@@ -159,6 +170,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
|
||||
for _, item := range c.Views {
|
||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
||||
@@ -190,6 +202,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
for _, item := range c.StatusBarItems {
|
||||
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
|
||||
}
|
||||
for _, item := range c.OpenProviders {
|
||||
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes all contributions from a plugin.
|
||||
@@ -207,6 +222,7 @@ func (r *Registry) Unregister(pluginID string) {
|
||||
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
}
|
||||
|
||||
// Getters — sorted for deterministic display.
|
||||
@@ -274,6 +290,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Registry) OpenProviders() []ContributionOpenProvider {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]ContributionOpenProvider, len(r.openProviders))
|
||||
copy(result, r.openProviders)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].PluginID != result[j].PluginID {
|
||||
return result[i].PluginID < result[j].PluginID
|
||||
}
|
||||
return result[i].Item.ID < result[j].Item.ID
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Remove helpers ─────────────────────────────────────────
|
||||
|
||||
func removeViews(items []ContributionView, pluginID string) []ContributionView {
|
||||
@@ -365,3 +395,13 @@ func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []ContributionOpenProvider {
|
||||
var result []ContributionOpenProvider
|
||||
for _, item := range items {
|
||||
if item.PluginID != pluginID {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -184,6 +184,17 @@ func TestListByPoint(t *testing.T) {
|
||||
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
|
||||
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
|
||||
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
|
||||
OpenProviders: []plugin.ContributionOpenProvider{{
|
||||
ID: "op1",
|
||||
Title: "Open Provider 1",
|
||||
Priority: 100,
|
||||
Component: "OpenProvider",
|
||||
Supports: []plugin.OpenProviderSupport{{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{"generic-markdown", "notes-markdown"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
r.Register("test.plugin", contrib)
|
||||
@@ -202,6 +213,7 @@ func TestListByPoint(t *testing.T) {
|
||||
{PointSearchProviders, 1},
|
||||
{PointActivity, 1},
|
||||
{PointStatusBar, 1},
|
||||
{PointOpenProviders, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -212,6 +224,55 @@ func TestListByPoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProviders_RegisterReplaceUnregister(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
r.Register("editor.plugin", &plugin.Contributions{
|
||||
OpenProviders: []plugin.ContributionOpenProvider{{
|
||||
ID: "editor.markdown",
|
||||
Title: "Markdown",
|
||||
Priority: 50,
|
||||
Component: "MarkdownEditor",
|
||||
Supports: []plugin.OpenProviderSupport{{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md", ".markdown"},
|
||||
Contexts: []string{"generic-markdown", "notes-markdown"},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
|
||||
providers := r.OpenProviders()
|
||||
if len(providers) != 1 {
|
||||
t.Fatalf("OpenProviders count = %d, want 1", len(providers))
|
||||
}
|
||||
if providers[0].PluginID != "editor.plugin" || providers[0].Item.Component != "MarkdownEditor" {
|
||||
t.Fatalf("provider = %+v", providers[0])
|
||||
}
|
||||
|
||||
r.Register("editor.plugin", &plugin.Contributions{
|
||||
OpenProviders: []plugin.ContributionOpenProvider{{
|
||||
ID: "editor.text",
|
||||
Title: "Text",
|
||||
Priority: 10,
|
||||
Component: "TextEditor",
|
||||
Supports: []plugin.OpenProviderSupport{{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
|
||||
providers = r.OpenProviders()
|
||||
if len(providers) != 1 || providers[0].Item.ID != "editor.text" {
|
||||
t.Fatalf("providers after replace = %+v", providers)
|
||||
}
|
||||
|
||||
r.Unregister("editor.plugin")
|
||||
if got := len(r.OpenProviders()); got != 0 {
|
||||
t.Fatalf("OpenProviders after unregister = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
|
||||
// (simulating reload) and checks contributions appear only once (no duplicates).
|
||||
// This is the KEY TEST for idempotent re-registration.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func NormalizeRelativeDir(relativeDir string) (string, error) {
|
||||
return normalizeRelativePath(relativeDir, true)
|
||||
}
|
||||
|
||||
func NormalizeRelativeFile(relativePath string) (string, error) {
|
||||
return normalizeRelativePath(relativePath, false)
|
||||
}
|
||||
|
||||
func IsReservedPath(relativePath string) bool {
|
||||
normalized := strings.ReplaceAll(relativePath, "\\", "/")
|
||||
cleaned := path.Clean(normalized)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
}
|
||||
|
||||
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
|
||||
if strings.Contains(input, "\x00") {
|
||||
return "", fmt.Errorf("invalid-path: null-byte")
|
||||
}
|
||||
if strings.Contains(input, "\\") {
|
||||
return "", fmt.Errorf("invalid-path: backslash not allowed")
|
||||
}
|
||||
if looksAbsolute(input) {
|
||||
return "", fmt.Errorf("invalid-path: absolute path rejected")
|
||||
}
|
||||
|
||||
normalized := input
|
||||
for _, part := range strings.Split(normalized, "/") {
|
||||
if part == ".." {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
}
|
||||
|
||||
cleaned := path.Clean(normalized)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
if cleaned == "" && !allowRoot {
|
||||
return "", fmt.Errorf("invalid-path: empty path")
|
||||
}
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
if IsReservedPathNoNormalize(cleaned) {
|
||||
return "", fmt.Errorf("reserved-path: .verstak is internal")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func IsReservedPathNoNormalize(cleaned string) bool {
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
first := strings.Split(cleaned, "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
}
|
||||
|
||||
func looksAbsolute(input string) bool {
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(input) || strings.HasPrefix(input, "/") || strings.HasPrefix(input, "\\\\") || strings.HasPrefix(input, "\\") {
|
||||
return true
|
||||
}
|
||||
if len(input) >= 2 && input[1] == ':' && unicode.IsLetter(rune(input[0])) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRelativeDirAllowsRootAndPreservesCase(t *testing.T) {
|
||||
got, err := NormalizeRelativeDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeDir root: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("root dir = %q, want empty", got)
|
||||
}
|
||||
|
||||
got, err = NormalizeRelativeDir("Notes/Overview.md")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeDir preserves case: %v", err)
|
||||
}
|
||||
if got != "Notes/Overview.md" {
|
||||
t.Fatalf("path = %q, want Notes/Overview.md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"/etc/passwd",
|
||||
"C:\\Users\\file.txt",
|
||||
"C:/Windows/system.ini",
|
||||
`\\server\share`,
|
||||
"//server/share",
|
||||
`..\secret`,
|
||||
`folder\..\secret`,
|
||||
"../outside.txt",
|
||||
"folder/../../outside.txt",
|
||||
`folder\sub/../../secret`,
|
||||
`folder\sub`,
|
||||
"bad\x00name.txt",
|
||||
".verstak",
|
||||
".verstak/",
|
||||
".verstak/vault.json",
|
||||
"./.verstak",
|
||||
".verstak/trash",
|
||||
"folder/../.verstak",
|
||||
".Verstak",
|
||||
}
|
||||
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := NormalizeRelativeFile(input)
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeRelativeFile(%q): expected error", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservedPathPolicy(t *testing.T) {
|
||||
if !IsReservedPath(".verstak") {
|
||||
t.Fatal(".verstak should be reserved")
|
||||
}
|
||||
if !IsReservedPath(".verstak/trash/file.txt") {
|
||||
t.Fatal(".verstak/trash/file.txt should be reserved")
|
||||
}
|
||||
if !IsReservedPath(".Verstak/trash/file.txt") {
|
||||
t.Fatal(".Verstak/trash/file.txt should be reserved by case-insensitive policy")
|
||||
}
|
||||
if IsReservedPath("Notes/.verstak.md") {
|
||||
t.Fatal("Notes/.verstak.md should not be reserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
|
||||
got, err := NormalizeRelativeFile("Notes/Overview.md")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeRelativeFile valid slash path: %v", err)
|
||||
}
|
||||
if got != "Notes/Overview.md" {
|
||||
t.Fatalf("path = %q, want Notes/Overview.md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathPolicyErrorsAreReadable(t *testing.T) {
|
||||
_, err := NormalizeRelativeFile("../outside.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path-traversal") {
|
||||
t.Fatalf("error = %q, want path-traversal", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
func NewService(v *vault.Vault) *Service {
|
||||
return &Service{vault: v}
|
||||
}
|
||||
|
||||
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
|
||||
root, err := s.vaultRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, err := NormalizeRelativeDir(relativeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full, err := s.resolve(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("not-directory: %s", rel)
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]FileEntry, 0, len(dirEntries))
|
||||
for _, dirEntry := range dirEntries {
|
||||
childRel := joinRel(rel, dirEntry.Name())
|
||||
if IsReservedPathNoNormalize(childRel) {
|
||||
continue
|
||||
}
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, makeEntry(childRel, info))
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, false); err != nil {
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return FileMetadata{}, err
|
||||
}
|
||||
return makeMetadata(rel, info), nil
|
||||
}
|
||||
|
||||
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("not-regular-file: %s", rel)
|
||||
}
|
||||
if info.Size() > MaxTextFileBytes {
|
||||
return "", fmt.Errorf("file-too-large: %s", rel)
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return "", fmt.Errorf("not-text-file: %s", rel)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(full)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
||||
}
|
||||
|
||||
existing, err := os.Lstat(full)
|
||||
if err == nil {
|
||||
if existing.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
if !existing.Mode().IsRegular() {
|
||||
return fmt.Errorf("not-regular-file: %s", rel)
|
||||
}
|
||||
if !options.Overwrite {
|
||||
return fmt.Errorf("conflict: %s", rel)
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if !options.CreateIfMissing {
|
||||
return fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, full); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateVaultFolder(relativePath string) error {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(full); err == nil {
|
||||
return fmt.Errorf("conflict: %s", rel)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(full)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
|
||||
}
|
||||
return os.Mkdir(full, 0o755)
|
||||
}
|
||||
|
||||
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
|
||||
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, toRel, toFull, err := s.resolveFile(toRelativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromRel == "" || toRel == "" {
|
||||
return fmt.Errorf("invalid-path: cannot move root")
|
||||
}
|
||||
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, toRel, false); err != nil {
|
||||
return err
|
||||
}
|
||||
fromInfo, err := os.Lstat(fromFull)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("not-found: %s", fromRel)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fromInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
|
||||
}
|
||||
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
|
||||
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
|
||||
}
|
||||
parent := filepath.Dir(toFull)
|
||||
if info, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
|
||||
}
|
||||
return err
|
||||
} else if !info.IsDir() {
|
||||
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
|
||||
}
|
||||
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
|
||||
return fmt.Errorf("conflict: %s", toRel)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Rename(fromFull, toFull)
|
||||
}
|
||||
|
||||
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
||||
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
|
||||
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
|
||||
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := os.Rename(full, trashFull); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
result := TrashResult{
|
||||
OriginalPath: rel,
|
||||
TrashPath: trashRel,
|
||||
TrashID: trashID,
|
||||
DeletedAt: deletedAt,
|
||||
}
|
||||
meta := map[string]string{
|
||||
"originalPath": rel,
|
||||
"trashPath": trashRel,
|
||||
"trashId": trashID,
|
||||
"deletedAt": deletedAt,
|
||||
"originalType": string(fileTypeFromInfo(info)),
|
||||
"basename": filepath.Base(rel),
|
||||
"type": string(fileTypeFromInfo(info)),
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) vaultRoot() (string, error) {
|
||||
if s == nil || s.vault == nil {
|
||||
return "", fmt.Errorf("vault-not-initialized")
|
||||
}
|
||||
if s.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
return "", fmt.Errorf("vault-not-open")
|
||||
}
|
||||
root := s.vault.GetVaultPath()
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("vault-not-open")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
|
||||
root, err := s.vaultRoot()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
rel, err := NormalizeRelativeFile(relativePath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
full, err := s.resolve(root, rel)
|
||||
return root, rel, full, err
|
||||
}
|
||||
|
||||
func (s *Service) resolve(root, rel string) (string, error) {
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absFull, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
relToRoot, err := filepath.Rel(absRoot, absFull)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
|
||||
return "", fmt.Errorf("invalid-path: path-traversal")
|
||||
}
|
||||
return absFull, nil
|
||||
}
|
||||
|
||||
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
|
||||
if rel == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
limit := len(parts)
|
||||
if !includeFinal {
|
||||
limit--
|
||||
}
|
||||
current := root
|
||||
for i := 0; i < limit; i++ {
|
||||
current = filepath.Join(current, filepath.FromSlash(parts[i]))
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeEntry(rel string, info fs.FileInfo) FileEntry {
|
||||
t := fileTypeFromInfo(info)
|
||||
return FileEntry{
|
||||
Name: info.Name(),
|
||||
RelativePath: rel,
|
||||
Type: t,
|
||||
Size: sizeForType(t, info),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
|
||||
IsHidden: strings.HasPrefix(info.Name(), "."),
|
||||
IsReserved: IsReservedPathNoNormalize(rel),
|
||||
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
||||
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
|
||||
t := fileTypeFromInfo(info)
|
||||
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
|
||||
return FileMetadata{
|
||||
RelativePath: rel,
|
||||
Type: t,
|
||||
Size: sizeForType(t, info),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||||
Extension: ext,
|
||||
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
|
||||
IsText: isTextExtension(ext),
|
||||
IsHidden: strings.HasPrefix(info.Name(), "."),
|
||||
IsReserved: IsReservedPathNoNormalize(rel),
|
||||
CanRead: t == FileTypeFile || t == FileTypeFolder,
|
||||
CanWrite: t == FileTypeFile || t == FileTypeFolder,
|
||||
}
|
||||
}
|
||||
|
||||
func fileTypeFromInfo(info fs.FileInfo) FileType {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return FileTypeSymlink
|
||||
}
|
||||
if info.IsDir() {
|
||||
return FileTypeFolder
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
return FileTypeFile
|
||||
}
|
||||
return FileTypeUnknown
|
||||
}
|
||||
|
||||
func sizeForType(t FileType, info fs.FileInfo) int64 {
|
||||
if t == FileTypeFolder {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func isTextExtension(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func joinRel(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "/" + name
|
||||
}
|
||||
|
||||
func pathDir(rel string) string {
|
||||
dir := pathDirSlash(rel)
|
||||
if dir == "." {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func pathDirSlash(rel string) string {
|
||||
idx := strings.LastIndex(rel, "/")
|
||||
if idx < 0 {
|
||||
return "."
|
||||
}
|
||||
return rel[:idx]
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
return NewService(v), v.GetVaultPath()
|
||||
}
|
||||
|
||||
func TestServiceRequiresOpenVault(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
s := NewService(v)
|
||||
|
||||
if _, err := s.ListVaultFiles(""); err == nil {
|
||||
t.Fatal("ListVaultFiles with closed vault: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVaultFilesExcludesReservedAndReturnsEntries(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "readme.md"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVaultFiles: %v", err)
|
||||
}
|
||||
|
||||
names := map[string]FileEntry{}
|
||||
for _, entry := range entries {
|
||||
names[entry.Name] = entry
|
||||
if strings.HasPrefix(entry.RelativePath, ".verstak") {
|
||||
t.Fatalf("reserved entry leaked into list: %+v", entry)
|
||||
}
|
||||
}
|
||||
if names["readme.md"].Type != FileTypeFile {
|
||||
t.Fatalf("readme.md type = %q", names["readme.md"].Type)
|
||||
}
|
||||
if names["Docs"].Type != FileTypeFolder {
|
||||
t.Fatalf("Docs type = %q", names["Docs"].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
|
||||
s, _ := newTestService(t)
|
||||
|
||||
cases := []string{
|
||||
"/etc/passwd",
|
||||
"C:\\Windows\\system.ini",
|
||||
"C:/Windows/system.ini",
|
||||
`\\server\share`,
|
||||
"//server/share",
|
||||
`..\outside`,
|
||||
`folder\..\outside`,
|
||||
"../outside",
|
||||
"folder/../../outside",
|
||||
`folder\sub/../../outside`,
|
||||
"bad\x00path",
|
||||
".verstak",
|
||||
".verstak/",
|
||||
".verstak/vault.json",
|
||||
"./.verstak",
|
||||
".Verstak/trash",
|
||||
}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := s.GetVaultFileMetadata(input); err == nil {
|
||||
t.Fatal("metadata: expected error")
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile(input); err == nil {
|
||||
t.Fatal("read: expected error")
|
||||
}
|
||||
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||
t.Fatal("write: expected error")
|
||||
}
|
||||
if err := s.MoveVaultPath(input, "safe.txt", MoveOptions{}); err == nil {
|
||||
t.Fatal("move: expected error")
|
||||
}
|
||||
if _, err := s.TrashVaultPath(input); err == nil {
|
||||
t.Fatal("trash: expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVaultTextFileRules(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "huge.txt"), []byte(strings.Repeat("a", int(MaxTextFileBytes)+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
text, err := s.ReadVaultTextFile("note.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVaultTextFile note: %v", err)
|
||||
}
|
||||
if text != "hello\n" {
|
||||
t.Fatalf("text = %q", text)
|
||||
}
|
||||
|
||||
if _, err := s.ReadVaultTextFile("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
|
||||
t.Fatalf("read folder error = %v, want not-regular-file", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("missing.md"); err == nil || !strings.Contains(err.Error(), "not-found") {
|
||||
t.Fatalf("read missing error = %v, want not-found", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("huge.txt"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
|
||||
t.Fatalf("read huge error = %v, want file-too-large", err)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("binary.bin"); err == nil || !strings.Contains(err.Error(), "not-text-file") {
|
||||
t.Fatalf("read binary error = %v, want not-text-file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||
t.Fatal("write should fail when parent folder is missing")
|
||||
}
|
||||
if err := s.CreateVaultFolder("Notes"); err != nil {
|
||||
t.Fatalf("CreateVaultFolder: %v", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err != nil {
|
||||
t.Fatalf("write create: %v", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("write conflict error = %v, want conflict", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true, Overwrite: true}); err != nil {
|
||||
t.Fatalf("write overwrite: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, "Notes", "one.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "second" {
|
||||
t.Fatalf("file content = %q", string(data))
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(root, "Notes", ".verstak-write-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("atomic write left temp files: %v", matches)
|
||||
}
|
||||
|
||||
if err := s.WriteVaultTextFile("", "root", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "empty path") {
|
||||
t.Fatalf("write root error = %v, want empty path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVaultFolderConflict(t *testing.T) {
|
||||
s, _ := newTestService(t)
|
||||
if err := s.CreateVaultFolder("Folder"); err != nil {
|
||||
t.Fatalf("CreateVaultFolder first: %v", err)
|
||||
}
|
||||
if err := s.CreateVaultFolder("Folder"); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("CreateVaultFolder conflict error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveVaultPathRules(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.Mkdir(filepath.Join(root, "A"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "A", "one.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.MoveVaultPath("A/one.txt", "moved.txt", MoveOptions{}); err != nil {
|
||||
t.Fatalf("move file: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "moved.txt")); err != nil {
|
||||
t.Fatalf("moved file missing: %v", err)
|
||||
}
|
||||
|
||||
if err := s.MoveVaultPath("A", "B", MoveOptions{}); err != nil {
|
||||
t.Fatalf("move folder: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "C"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MoveVaultPath("C", "C/Child", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "move-into-self") {
|
||||
t.Fatalf("move into self error = %v, want move-into-self", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("moved.txt", "target.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("move conflict error = %v, want conflict", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("", "root-move", MoveOptions{}); err == nil {
|
||||
t.Fatal("move root should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "delete-me.txt"), []byte("bye"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "delete-folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "same.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Other"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Other", "same.txt"), []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileResult, err := s.TrashVaultPath("delete-me.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash file: %v", err)
|
||||
}
|
||||
if fileResult.OriginalPath != "delete-me.txt" || fileResult.TrashID == "" || fileResult.DeletedAt == "" {
|
||||
t.Fatalf("unexpected trash result: %+v", fileResult)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, fileResult.TrashPath)); err != nil {
|
||||
t.Fatalf("trashed file missing: %v", err)
|
||||
}
|
||||
metaPath := filepath.Join(root, ".verstak", "trash", "files", fileResult.TrashID, "metadata.json")
|
||||
metaData, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trash metadata missing: %v", err)
|
||||
}
|
||||
var meta map[string]string
|
||||
if err := json.Unmarshal(metaData, &meta); err != nil {
|
||||
t.Fatalf("trash metadata invalid JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"originalPath", "deletedAt", "originalType", "trashId", "basename"} {
|
||||
if meta[key] == "" {
|
||||
t.Fatalf("trash metadata missing %s: %s", key, string(metaData))
|
||||
}
|
||||
}
|
||||
if meta["basename"] != "delete-me.txt" || meta["originalType"] != string(FileTypeFile) {
|
||||
t.Fatalf("trash metadata = %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := s.TrashVaultPath("delete-folder"); err != nil {
|
||||
t.Fatalf("trash folder: %v", err)
|
||||
}
|
||||
firstSame, err := s.TrashVaultPath("same.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash same root: %v", err)
|
||||
}
|
||||
secondSame, err := s.TrashVaultPath("Other/same.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("trash same nested: %v", err)
|
||||
}
|
||||
if firstSame.TrashID == secondSame.TrashID || firstSame.TrashPath == secondSame.TrashPath {
|
||||
t.Fatalf("repeated trash basename collided: first=%+v second=%+v", firstSame, secondSame)
|
||||
}
|
||||
if _, err := s.TrashVaultPath(""); err == nil {
|
||||
t.Fatal("trash root should fail")
|
||||
}
|
||||
if _, err := s.TrashVaultPath("missing.txt"); err == nil || !strings.Contains(err.Error(), "not-found") {
|
||||
t.Fatalf("trash missing error = %v, want not-found", err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVaultFiles: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name == "delete-me.txt" || entry.Name == "delete-folder" || entry.Name == ".verstak" {
|
||||
t.Fatalf("unexpected entry after trash: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkEscapeRejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
outsideFile := filepath.Join(outside, "outside.txt")
|
||||
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideFile, filepath.Join(root, "escape.txt")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
meta, err := s.GetVaultFileMetadata("escape.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("metadata symlink: %v", err)
|
||||
}
|
||||
if meta.Type != FileTypeSymlink {
|
||||
t.Fatalf("symlink type = %q", meta.Type)
|
||||
}
|
||||
|
||||
if _, err := s.ReadVaultTextFile("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("read symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("escape.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("write symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("escape.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("move symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := s.TrashVaultPath("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("trash symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkInsideVaultRejectedForMutatingAndReadOperations(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("inside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "target.txt"), filepath.Join(root, "inside-link.txt")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
meta, err := s.GetVaultFileMetadata("inside-link.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("metadata inside symlink: %v", err)
|
||||
}
|
||||
if meta.Type != FileTypeSymlink {
|
||||
t.Fatalf("symlink type = %q", meta.Type)
|
||||
}
|
||||
if _, err := s.ReadVaultTextFile("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("read inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.WriteVaultTextFile("inside-link.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("write inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if err := s.MoveVaultPath("inside-link.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("move inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := s.TrashVaultPath("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("trash inside symlink error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(root, ".verstak-write-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("write symlink left root temp files: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVaultFilesRejectsSymlinkDirectoryEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.ListVaultFiles("outside-dir"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("list symlink dir error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
|
||||
entries, err := s.ListVaultFiles("")
|
||||
if err != nil {
|
||||
t.Fatalf("list root: %v", err)
|
||||
}
|
||||
var foundSymlink bool
|
||||
for _, entry := range entries {
|
||||
if entry.RelativePath == "outside-dir" {
|
||||
foundSymlink = true
|
||||
if entry.Type != FileTypeSymlink {
|
||||
t.Fatalf("root symlink entry type = %q, want symlink", entry.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSymlink {
|
||||
t.Fatal("root list should expose the symlink as metadata without following it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVaultFolderRejectsSymlinkParentEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||
}
|
||||
|
||||
s, root := newTestService(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
if err := s.CreateVaultFolder("outside-dir/new-folder"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
|
||||
t.Fatalf("create folder through symlink parent error = %v, want symlink-not-allowed", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "new-folder")); !os.IsNotExist(err) {
|
||||
t.Fatalf("folder should not be created outside vault, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package files
|
||||
|
||||
const MaxTextFileBytes int64 = 2 * 1024 * 1024
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeFile FileType = "file"
|
||||
FileTypeFolder FileType = "folder"
|
||||
FileTypeSymlink FileType = "symlink"
|
||||
FileTypeUnknown FileType = "unknown"
|
||||
)
|
||||
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
Type FileType `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
Extension string `json:"extension"`
|
||||
IsHidden bool `json:"isHidden"`
|
||||
IsReserved bool `json:"isReserved"`
|
||||
CanRead bool `json:"canRead"`
|
||||
CanWrite bool `json:"canWrite"`
|
||||
}
|
||||
|
||||
type FileMetadata struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Type FileType `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Extension string `json:"extension"`
|
||||
MimeHint string `json:"mimeHint"`
|
||||
IsText bool `json:"isText"`
|
||||
IsHidden bool `json:"isHidden"`
|
||||
IsReserved bool `json:"isReserved"`
|
||||
CanRead bool `json:"canRead"`
|
||||
CanWrite bool `json:"canWrite"`
|
||||
}
|
||||
|
||||
type WriteOptions struct {
|
||||
CreateIfMissing bool `json:"createIfMissing"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type MoveOptions struct {
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type TrashResult struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
TrashID string `json:"trashId"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
@@ -33,12 +33,16 @@ func (r *Registry) registerDefaults() {
|
||||
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
|
||||
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
|
||||
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
|
||||
{Name: "files.read", Description: "List files and read text files through the vault Files API", Dangerous: false},
|
||||
{Name: "files.write", Description: "Create folders, write text files, and move paths through the vault Files API", Dangerous: true},
|
||||
{Name: "files.delete", Description: "Trash vault files and folders through the vault Files API", Dangerous: true},
|
||||
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
|
||||
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
|
||||
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
|
||||
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
|
||||
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
|
||||
{Name: "commands.register", Description: "Register command palette commands", Dangerous: false},
|
||||
{Name: "workbench.open", Description: "Request Workbench open/edit routing for vault resources", Dangerous: false},
|
||||
{Name: "network.local", Description: "Connect to localhost network services", Dangerous: false},
|
||||
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
|
||||
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirResolveOptions makes plugin directory resolution testable.
|
||||
type DirResolveOptions struct {
|
||||
EnvPluginDir string
|
||||
CWD string
|
||||
ExecutablePath string
|
||||
UserConfigDir string
|
||||
HomeDir string
|
||||
}
|
||||
|
||||
// ResolveDiscoveryDirs returns plugin discovery directories in priority order:
|
||||
// explicit env override, dev ./plugins, packaged binary-adjacent plugins, user plugins.
|
||||
func ResolveDiscoveryDirs(opts DirResolveOptions) []string {
|
||||
var dirs []string
|
||||
add := func(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
for _, existing := range dirs {
|
||||
if existing == cleaned {
|
||||
return
|
||||
}
|
||||
}
|
||||
dirs = append(dirs, cleaned)
|
||||
}
|
||||
|
||||
if opts.EnvPluginDir != "" {
|
||||
for _, path := range filepath.SplitList(opts.EnvPluginDir) {
|
||||
add(path)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.CWD != "" {
|
||||
add(filepath.Join(opts.CWD, "plugins"))
|
||||
}
|
||||
|
||||
if opts.ExecutablePath != "" {
|
||||
add(filepath.Join(filepath.Dir(opts.ExecutablePath), "plugins"))
|
||||
}
|
||||
|
||||
if opts.UserConfigDir != "" {
|
||||
add(filepath.Join(opts.UserConfigDir, "verstak", "plugins"))
|
||||
} else if opts.HomeDir != "" {
|
||||
add(filepath.Join(opts.HomeDir, ".config", "verstak", "plugins"))
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// DefaultDiscoveryDirs resolves discovery directories from the current process.
|
||||
func DefaultDiscoveryDirs() []string {
|
||||
cwd, _ := os.Getwd()
|
||||
exe, _ := os.Executable()
|
||||
userConfig, _ := os.UserConfigDir()
|
||||
home, _ := os.UserHomeDir()
|
||||
return ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: strings.TrimSpace(os.Getenv("VERSTAK_PLUGIN_DIR")),
|
||||
CWD: cwd,
|
||||
ExecutablePath: exe,
|
||||
UserConfigDir: userConfig,
|
||||
HomeDir: home,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDiscoveryDirs_EnvCwdBinaryUserDedup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
envDir := filepath.Join(root, "env-plugins")
|
||||
cwdDir := filepath.Join(root, "repo", "plugins")
|
||||
binaryDir := filepath.Join(root, "app", "plugins")
|
||||
userConfigDir := filepath.Join(root, "config")
|
||||
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: envDir + string(filepath.ListSeparator) + cwdDir,
|
||||
CWD: filepath.Join(root, "repo"),
|
||||
ExecutablePath: filepath.Join(root, "app", "verstak-desktop"),
|
||||
UserConfigDir: userConfigDir,
|
||||
})
|
||||
|
||||
want := []string{
|
||||
envDir,
|
||||
cwdDir,
|
||||
binaryDir,
|
||||
filepath.Join(userConfigDir, "verstak", "plugins"),
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_UsesCwdWhenExecutablePathMissing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
CWD: root,
|
||||
HomeDir: filepath.Join(root, "home"),
|
||||
})
|
||||
|
||||
wantFirst := filepath.Join(root, "plugins")
|
||||
if got[0] != wantFirst {
|
||||
t.Fatalf("first plugin dir = %q, want cwd plugins %q", got[0], wantFirst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_FallsBackToHomeConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
HomeDir: filepath.Join(root, "home"),
|
||||
})
|
||||
|
||||
want := []string{filepath.Join(root, "home", ".config", "verstak", "plugins")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDiscoveryDirs_NormalizesAndDeduplicatesPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cwd := filepath.Join(root, "repo")
|
||||
got := ResolveDiscoveryDirs(DirResolveOptions{
|
||||
EnvPluginDir: filepath.Join(cwd, ".", "plugins") + string(filepath.ListSeparator) + filepath.Join(cwd, "plugins"),
|
||||
CWD: cwd,
|
||||
})
|
||||
|
||||
want := []string{filepath.Join(cwd, "plugins")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ type Contributions struct {
|
||||
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
|
||||
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
|
||||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionView represents a view contribution.
|
||||
@@ -144,6 +145,23 @@ type ContributionStatusBarItem struct {
|
||||
Handler string `json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
// OpenProviderSupport describes a resource shape an open provider can handle.
|
||||
type OpenProviderSupport struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime []string `json:"mime,omitempty"`
|
||||
Extensions []string `json:"extensions,omitempty"`
|
||||
Contexts []string `json:"contexts,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionOpenProvider represents an editor/viewer provider contribution.
|
||||
type ContributionOpenProvider struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Component string `json:"component"`
|
||||
Supports []OpenProviderSupport `json:"supports"`
|
||||
}
|
||||
|
||||
// SyncConfig describes plugin sync configuration.
|
||||
type SyncConfig struct {
|
||||
Namespaces []string `json:"namespaces,omitempty"`
|
||||
@@ -209,6 +227,27 @@ func ValidateManifest(m *Manifest) []string {
|
||||
if len(m.Permissions) == 0 {
|
||||
errs.add("permissions must have at least one permission")
|
||||
}
|
||||
if m.Contributes != nil {
|
||||
for i, provider := range m.Contributes.OpenProviders {
|
||||
if provider.ID == "" {
|
||||
errs.add("contributes.openProviders[%d].id is required", i)
|
||||
}
|
||||
if provider.Title == "" {
|
||||
errs.add("contributes.openProviders[%d].title is required", i)
|
||||
}
|
||||
if provider.Component == "" {
|
||||
errs.add("contributes.openProviders[%d].component is required", i)
|
||||
}
|
||||
if len(provider.Supports) == 0 {
|
||||
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
|
||||
}
|
||||
for j, support := range provider.Supports {
|
||||
if support.Kind == "" {
|
||||
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs.errors
|
||||
}
|
||||
@@ -249,7 +288,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||
var plugins []Plugin
|
||||
var errs []error
|
||||
|
||||
seen := make(map[string]bool)
|
||||
seen := make(map[string]string)
|
||||
|
||||
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
|
||||
|
||||
@@ -287,12 +326,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[plugin.Manifest.ID] {
|
||||
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
|
||||
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
|
||||
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
|
||||
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
|
||||
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
|
||||
continue
|
||||
}
|
||||
seen[plugin.Manifest.ID] = true
|
||||
seen[plugin.Manifest.ID] = pluginDir
|
||||
plugins = append(plugins, plugin)
|
||||
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,90 @@ func TestDiscoverPlugins_DuplicateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverPlugins_DuplicateIDAcrossDirs_FirstWinsAndReportsBothPaths(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
firstPath := createTempPlugin(t, dir1, "shared.plugin", "First")
|
||||
|
||||
secondPath := filepath.Join(dir2, "other-name")
|
||||
if err := os.MkdirAll(secondPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{
|
||||
"schemaVersion": 1,
|
||||
"id": "shared.plugin",
|
||||
"name": "Second",
|
||||
"version": "2.0.0",
|
||||
"apiVersion": "1.0",
|
||||
"provides": ["shared.plugin.second.cap"],
|
||||
"permissions": ["vault.read"]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(secondPath, "plugin.json"), []byte(manifest), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected first plugin only, got %d", len(plugins))
|
||||
}
|
||||
if plugins[0].RootPath != firstPath {
|
||||
t.Fatalf("winner path = %q, want %q", plugins[0].RootPath, firstPath)
|
||||
}
|
||||
|
||||
combined := ""
|
||||
for _, err := range errs {
|
||||
combined += err.Error()
|
||||
}
|
||||
if !strings.Contains(combined, "duplicate plugin ID") {
|
||||
t.Fatalf("expected duplicate error, got %v", errs)
|
||||
}
|
||||
if !strings.Contains(combined, firstPath) || !strings.Contains(combined, secondPath) {
|
||||
t.Fatalf("duplicate error should include both paths; got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifest_OpenProviders(t *testing.T) {
|
||||
valid := &Manifest{
|
||||
SchemaVersion: 1,
|
||||
ID: "editor.plugin",
|
||||
Name: "Editor",
|
||||
Version: "1.0.0",
|
||||
APIVersion: "1.0",
|
||||
Provides: []string{"editor.text"},
|
||||
Permissions: []string{"workbench.open"},
|
||||
Contributes: &Contributions{
|
||||
OpenProviders: []ContributionOpenProvider{{
|
||||
ID: "editor.text",
|
||||
Title: "Text Editor",
|
||||
Component: "TextEditor",
|
||||
Supports: []OpenProviderSupport{{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{"generic-text"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if errs := ValidateManifest(valid); len(errs) != 0 {
|
||||
t.Fatalf("valid manifest errors = %v", errs)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.Contributes = &Contributions{
|
||||
OpenProviders: []ContributionOpenProvider{{
|
||||
ID: "broken",
|
||||
Title: "Broken",
|
||||
Component: "",
|
||||
Supports: []OpenProviderSupport{{}},
|
||||
}},
|
||||
}
|
||||
errs := ValidateManifest(&invalid)
|
||||
combined := strings.Join(errs, "\n")
|
||||
if !strings.Contains(combined, "component is required") || !strings.Contains(combined, "kind is required") {
|
||||
t.Fatalf("expected open provider validation errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Package workbench routes open/edit resource requests to contributed providers.
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
)
|
||||
|
||||
type Preferences struct {
|
||||
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
|
||||
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
|
||||
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
ContextGenericText = "generic-text"
|
||||
ContextGenericMarkdown = "generic-markdown"
|
||||
ContextNotesMarkdown = "notes-markdown"
|
||||
)
|
||||
|
||||
type OpenResourceContext struct {
|
||||
SourcePluginID string `json:"sourcePluginId,omitempty"`
|
||||
SourceView string `json:"sourceView,omitempty"`
|
||||
IsInsideNotesFolder bool `json:"isInsideNotesFolder,omitempty"`
|
||||
NotesScopePath string `json:"notesScopePath,omitempty"`
|
||||
NotesMode bool `json:"notesMode,omitempty"`
|
||||
}
|
||||
|
||||
type OpenResourceRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Extension string `json:"extension,omitempty"`
|
||||
Context OpenResourceContext `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
type OpenResourceResult struct {
|
||||
Status string `json:"status"`
|
||||
ProviderID string `json:"providerId,omitempty"`
|
||||
ProviderPluginID string `json:"providerPluginId,omitempty"`
|
||||
ProviderComponent string `json:"providerComponent,omitempty"`
|
||||
Request OpenResourceRequest `json:"request"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type OpenedResource struct {
|
||||
ID string `json:"id"`
|
||||
ProviderID string `json:"providerId"`
|
||||
ProviderPluginID string `json:"providerPluginId"`
|
||||
ProviderComponent string `json:"providerComponent"`
|
||||
Request OpenResourceRequest `json:"request"`
|
||||
OpenedAt string `json:"openedAt"`
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
preferences Preferences
|
||||
opened []OpenedResource
|
||||
}
|
||||
|
||||
func NewRouter(preferences Preferences) *Router {
|
||||
return &Router{preferences: preferences}
|
||||
}
|
||||
|
||||
func (r *Router) Preferences() Preferences {
|
||||
return r.preferences
|
||||
}
|
||||
|
||||
func (r *Router) SetPreferences(preferences Preferences) {
|
||||
r.preferences = preferences
|
||||
}
|
||||
|
||||
func (r *Router) SelectProvider(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (contribution.ContributionOpenProvider, error) {
|
||||
request = normalizeRequest(request)
|
||||
var matches []contribution.ContributionOpenProvider
|
||||
for _, provider := range providers {
|
||||
if providerMatches(request, provider.Item) {
|
||||
matches = append(matches, provider)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return contribution.ContributionOpenProvider{}, fmt.Errorf("no open provider supports %s %q", request.Kind, request.Path)
|
||||
}
|
||||
|
||||
preferred := r.preferenceFor(request)
|
||||
if preferred != "" {
|
||||
for _, provider := range matches {
|
||||
if provider.Item.ID == preferred {
|
||||
return provider, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].Item.Priority != matches[j].Item.Priority {
|
||||
return matches[i].Item.Priority > matches[j].Item.Priority
|
||||
}
|
||||
if matches[i].PluginID != matches[j].PluginID {
|
||||
return matches[i].PluginID < matches[j].PluginID
|
||||
}
|
||||
return matches[i].Item.ID < matches[j].Item.ID
|
||||
})
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (r *Router) OpenResource(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (OpenResourceResult, error) {
|
||||
request = normalizeRequest(request)
|
||||
provider, err := r.SelectProvider(request, providers)
|
||||
if err != nil {
|
||||
return OpenResourceResult{
|
||||
Status: "no-provider",
|
||||
Request: request,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
result := OpenResourceResult{
|
||||
Status: "opened",
|
||||
ProviderID: provider.Item.ID,
|
||||
ProviderPluginID: provider.PluginID,
|
||||
ProviderComponent: provider.Item.Component,
|
||||
Request: request,
|
||||
}
|
||||
r.opened = append(r.opened, OpenedResource{
|
||||
ID: fmt.Sprintf("%s:%d", provider.Item.ID, len(r.opened)+1),
|
||||
ProviderID: result.ProviderID,
|
||||
ProviderPluginID: result.ProviderPluginID,
|
||||
ProviderComponent: result.ProviderComponent,
|
||||
Request: result.Request,
|
||||
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Router) OpenedResources() []OpenedResource {
|
||||
result := make([]OpenedResource, len(r.opened))
|
||||
copy(result, r.opened)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeRequest(request OpenResourceRequest) OpenResourceRequest {
|
||||
if request.Mode == "" {
|
||||
request.Mode = "view"
|
||||
}
|
||||
if request.Extension == "" {
|
||||
request.Extension = path.Ext(request.Path)
|
||||
}
|
||||
request.Extension = strings.ToLower(request.Extension)
|
||||
request.Mime = strings.ToLower(request.Mime)
|
||||
return request
|
||||
}
|
||||
|
||||
// DetermineContextName derives the current routing context from a request.
|
||||
// Future Files/Notes callers can move canonical Notes folder auto-detection here.
|
||||
func DetermineContextName(request OpenResourceRequest) string {
|
||||
request = normalizeRequest(request)
|
||||
return resourceContextName(request)
|
||||
}
|
||||
|
||||
func providerMatches(request OpenResourceRequest, provider plugin.ContributionOpenProvider) bool {
|
||||
for _, support := range provider.Supports {
|
||||
if support.Kind != request.Kind {
|
||||
continue
|
||||
}
|
||||
if !supportMatchesExtensionOrMime(request, support) {
|
||||
continue
|
||||
}
|
||||
if !supportMatchesContext(request, support) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func supportMatchesExtensionOrMime(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
|
||||
hasExtensionRules := len(support.Extensions) > 0
|
||||
hasMimeRules := len(support.Mime) > 0
|
||||
if !hasExtensionRules && !hasMimeRules {
|
||||
return true
|
||||
}
|
||||
|
||||
if hasExtensionRules {
|
||||
for _, ext := range support.Extensions {
|
||||
if strings.ToLower(ext) == request.Extension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasMimeRules && request.Mime != "" {
|
||||
for _, mime := range support.Mime {
|
||||
if strings.ToLower(mime) == request.Mime {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func supportMatchesContext(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
|
||||
if len(support.Contexts) == 0 {
|
||||
return true
|
||||
}
|
||||
context := resourceContextName(request)
|
||||
for _, supported := range support.Contexts {
|
||||
if supported == context {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Router) preferenceFor(request OpenResourceRequest) string {
|
||||
context := resourceContextName(request)
|
||||
switch {
|
||||
case context == ContextNotesMarkdown:
|
||||
return r.preferences.DefaultNotesMarkdownEditorProvider
|
||||
case context == ContextGenericMarkdown:
|
||||
return r.preferences.DefaultMarkdownEditorProvider
|
||||
case context == ContextGenericText:
|
||||
return r.preferences.DefaultTextEditorProvider
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resourceContextName(request OpenResourceRequest) string {
|
||||
ext := strings.ToLower(request.Extension)
|
||||
if ext == ".md" || ext == ".markdown" {
|
||||
if request.Context.NotesMode || request.Context.IsInsideNotesFolder {
|
||||
return ContextNotesMarkdown
|
||||
}
|
||||
return ContextGenericMarkdown
|
||||
}
|
||||
if isTextResource(request) {
|
||||
return ContextGenericText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTextResource(request OpenResourceRequest) bool {
|
||||
if strings.HasPrefix(request.Mime, "text/") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(request.Extension) {
|
||||
case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
)
|
||||
|
||||
func provider(pluginID, id string, priority int, component string, supports ...plugin.OpenProviderSupport) contribution.ContributionOpenProvider {
|
||||
return contribution.ContributionOpenProvider{
|
||||
PluginID: pluginID,
|
||||
Item: plugin.ContributionOpenProvider{
|
||||
ID: id,
|
||||
Title: id,
|
||||
Priority: priority,
|
||||
Component: component,
|
||||
Supports: supports,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderUsesNotesMarkdownPreference(t *testing.T) {
|
||||
r := NewRouter(Preferences{
|
||||
DefaultNotesMarkdownEditorProvider: "community.notes-editor",
|
||||
})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("official.editor", "official.markdown", 100, "OfficialMarkdown", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md", ".markdown"},
|
||||
Contexts: []string{"generic-markdown", "notes-markdown"},
|
||||
}),
|
||||
provider("community.editor", "community.notes-editor", 10, "CommunityNotes", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{"notes-markdown"},
|
||||
}),
|
||||
}
|
||||
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Clients/Acme/Notes/Overview.md",
|
||||
Extension: ".md",
|
||||
Mode: "edit",
|
||||
Context: OpenResourceContext{
|
||||
SourceView: "notes",
|
||||
IsInsideNotesFolder: true,
|
||||
NotesMode: true,
|
||||
},
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "community.notes-editor" {
|
||||
t.Fatalf("provider = %q, want community.notes-editor", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderFallsBackByPriorityThenID(t *testing.T) {
|
||||
r := NewRouter(Preferences{
|
||||
DefaultMarkdownEditorProvider: "disabled.or.missing",
|
||||
})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("b.plugin", "b.provider", 100, "B", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{"generic-markdown"},
|
||||
}),
|
||||
provider("a.plugin", "a.provider", 100, "A", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{"generic-markdown"},
|
||||
}),
|
||||
provider("high.plugin", "high.provider", 200, "High", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
}),
|
||||
}
|
||||
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/readme.md",
|
||||
Extension: ".md",
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "a.provider" {
|
||||
t.Fatalf("provider = %q, want deterministic tie winner a.provider", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderTieBreaksByPluginIDThenProviderID(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("b.plugin", "a.provider", 100, "B", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{ContextGenericMarkdown},
|
||||
}),
|
||||
provider("a.plugin", "z.provider", 100, "A", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{ContextGenericMarkdown},
|
||||
}),
|
||||
}
|
||||
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/readme.md",
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.PluginID != "a.plugin" || selected.Item.ID != "z.provider" {
|
||||
t.Fatalf("provider = %+v, want a.plugin/z.provider", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderMatchesGenericTextContext(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/readme.txt",
|
||||
Mode: "view",
|
||||
}, []contribution.ContributionOpenProvider{
|
||||
provider("text.plugin", "text.provider", 10, "Text", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{ContextGenericText},
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "text.provider" {
|
||||
t.Fatalf("provider = %q, want text.provider", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericMarkdownDoesNotSelectNotesOnlyProvider(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
_, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/readme.md",
|
||||
Mode: "view",
|
||||
}, []contribution.ContributionOpenProvider{
|
||||
provider("notes.plugin", "notes.provider", 10, "Notes", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{ContextNotesMarkdown},
|
||||
}),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no provider for generic markdown with notes-only provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenResourceStoresSelectedProviderAndRequest(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
result, err := r.OpenResource(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Notes/Overview.md",
|
||||
Extension: ".md",
|
||||
Mode: "edit",
|
||||
Context: OpenResourceContext{
|
||||
IsInsideNotesFolder: true,
|
||||
NotesMode: true,
|
||||
},
|
||||
}, []contribution.ContributionOpenProvider{
|
||||
provider("official.editor", "official.markdown", 100, "MarkdownEditor", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{"notes-markdown"},
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenResource: %v", err)
|
||||
}
|
||||
if result.Status != "opened" || result.ProviderID != "official.markdown" || result.ProviderComponent != "MarkdownEditor" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
opened := r.OpenedResources()
|
||||
if len(opened) != 1 || opened[0].Request.Path != "Notes/Overview.md" {
|
||||
t.Fatalf("opened = %+v", opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
result, err := r.OpenResource(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/unknown.bin",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenResource: %v", err)
|
||||
}
|
||||
if result.Status != "no-provider" || result.Request.Path != "Docs/unknown.bin" || result.Message == "" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if len(r.OpenedResources()) != 0 {
|
||||
t.Fatalf("no-provider result should not store opened resource: %+v", r.OpenedResources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineContextName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request OpenResourceRequest
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "text",
|
||||
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.txt"},
|
||||
want: ContextGenericText,
|
||||
},
|
||||
{
|
||||
name: "markdown",
|
||||
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.md"},
|
||||
want: ContextGenericMarkdown,
|
||||
},
|
||||
{
|
||||
name: "notes markdown",
|
||||
request: OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Notes/Overview.md",
|
||||
Context: OpenResourceContext{
|
||||
IsInsideNotesFolder: true,
|
||||
NotesMode: true,
|
||||
},
|
||||
},
|
||||
want: ContextNotesMarkdown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DetermineContextName(tt.request); got != tt.want {
|
||||
t.Fatalf("DetermineContextName = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package debug provides a debug logger that writes to a file.
|
||||
// Enabled with --debug CLI flag.
|
||||
package debug
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *log.Logger
|
||||
mu sync.Mutex
|
||||
enabled bool
|
||||
)
|
||||
|
||||
// Init initializes the debug logger. If --debug is present in args,
|
||||
// it writes to ~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log.
|
||||
// Returns true if debug mode is enabled.
|
||||
func Init(args []string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for _, a := range args {
|
||||
if a == "--debug" {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// Create log directory
|
||||
logDir := filepath.Join(os.Getenv("HOME"), ".local", "share", "verstak", "debug")
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
log.Printf("[debug] failed to create log dir %s: %v", logDir, err)
|
||||
// Fallback to /tmp
|
||||
logDir = filepath.Join(os.TempDir(), "verstak-debug")
|
||||
os.MkdirAll(logDir, 0755)
|
||||
}
|
||||
|
||||
// Create log file with timestamp
|
||||
timestamp := time.Now().Format("2006-01-02-150405")
|
||||
logFile := filepath.Join(logDir, fmt.Sprintf("verstak-%s.log", timestamp))
|
||||
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Printf("[debug] failed to open log file %s: %v", logFile, err)
|
||||
return true // Still enabled, but logging to stderr
|
||||
}
|
||||
|
||||
// Write to both file and stderr
|
||||
mw := io.MultiWriter(f, os.Stderr)
|
||||
logger = log.New(mw, "", log.LstdFlags|log.Lmicroseconds)
|
||||
|
||||
log.Printf("[debug] logger initialized: %s", logFile)
|
||||
return true
|
||||
}
|
||||
|
||||
// IsEnabled returns whether debug mode is active.
|
||||
func IsEnabled() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return enabled
|
||||
}
|
||||
|
||||
// Logf writes a formatted debug message.
|
||||
func Logf(format string, v ...interface{}) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// Log writes a debug message.
|
||||
func Log(v ...interface{}) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Println(v...)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user