Implement milestone 6b workbench routing skeleton

This commit is contained in:
2026-06-19 07:51:57 +08:00
parent a100f5a441
commit 6ed6df311a
53 changed files with 7592 additions and 335 deletions
+71
View File
@@ -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,
})
}
+71
View File
@@ -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)
}
}
+44 -5
View File
@@ -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)
}
+84
View File
@@ -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()