feat: milestones 6b-fix through 6e — default-editor, files plugin, workspace host, workspaceItems contribution
- Fix PluginCard openProviders display - Add default-editor plugin (text/markdown/notes-context) - Add files plugin with workspaceItems placement - Add workspaceItems contribution point (Go + API + mock + SDK) - Add WorkspaceHost component for workspace area - WorkspaceTree dispatches selection event - Fix default-editor layout to fill container - Fix PluginCard unsafe .length access - Add E2E tests: 34/34 pass - Add bundle execution check to official-plugins check.sh - Update docs: PLUGIN_RUNTIME, DEV_PLUGINS, MILESTONE_6B/6C/6D plans
This commit is contained in:
+15
-1
@@ -240,6 +240,14 @@ type FlatOpenProvider struct {
|
||||
Supports []FlatOpenProviderSupport `json:"supports"`
|
||||
}
|
||||
|
||||
type FlatWorkspaceItem struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// ContributionSummary aggregates all contribution types for the frontend.
|
||||
type ContributionSummary struct {
|
||||
Views []FlatView `json:"views"`
|
||||
@@ -247,6 +255,7 @@ type ContributionSummary struct {
|
||||
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
||||
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
||||
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
||||
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
|
||||
}
|
||||
|
||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||
@@ -259,6 +268,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
regPanels := r.SettingsPanels()
|
||||
regSidebar := r.SidebarItems()
|
||||
regOpenProviders := r.OpenProviders()
|
||||
regWorkspaceItems := r.WorkspaceItems()
|
||||
|
||||
views := make([]FlatView, len(regViews))
|
||||
for i, v := range regViews {
|
||||
@@ -291,7 +301,11 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
Supports: supports,
|
||||
}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders}
|
||||
workspaceItems := make([]FlatWorkspaceItem, len(regWorkspaceItems))
|
||||
for i, v := range regWorkspaceItems {
|
||||
workspaceItems[i] = FlatWorkspaceItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders, WorkspaceItems: workspaceItems}
|
||||
}
|
||||
|
||||
// GetContributions returns all registered contributions flattened for the frontend.
|
||||
|
||||
@@ -668,6 +668,25 @@ func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
|
||||
"kind": "vault-file",
|
||||
"path": "Docs/readme.md",
|
||||
"extension": ".md",
|
||||
})
|
||||
if errStr != "" {
|
||||
t.Fatalf("OpenWorkbenchResource: %s", errStr)
|
||||
}
|
||||
if result.ProviderPluginID != "bridge.plugin" {
|
||||
t.Fatalf("expected bridge.plugin provider, got providerPluginId=%q", result.ProviderPluginID)
|
||||
}
|
||||
if result.ProviderID == "disabled.markdown" {
|
||||
t.Fatal("disabled plugin provider should be excluded from selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type Registry struct {
|
||||
activityProviders []ContributionActivityProvider
|
||||
statusBarItems []ContributionStatusBarItem
|
||||
openProviders []ContributionOpenProvider
|
||||
workspaceItems []ContributionWorkspaceItem
|
||||
}
|
||||
|
||||
// ContributionPointType defines the type of contribution point.
|
||||
@@ -40,6 +41,7 @@ const (
|
||||
PointActivity ContributionPointType = "activityProviders"
|
||||
PointStatusBar ContributionPointType = "statusBarItems"
|
||||
PointOpenProviders ContributionPointType = "openProviders"
|
||||
PointWorkspaceItems ContributionPointType = "workspaceItems"
|
||||
)
|
||||
|
||||
// ListByPoint returns all contributions for a given point type.
|
||||
@@ -93,6 +95,10 @@ func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
|
||||
for _, v := range r.openProviders {
|
||||
result = append(result, v)
|
||||
}
|
||||
case PointWorkspaceItems:
|
||||
for _, v := range r.workspaceItems {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -147,6 +153,12 @@ type ContributionOpenProvider struct {
|
||||
Item plugin.ContributionOpenProvider `json:"item"`
|
||||
}
|
||||
|
||||
// ContributionWorkspaceItem is a workspace tool contribution.
|
||||
type ContributionWorkspaceItem struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
Item plugin.ContributionWorkspaceItem `json:"item"`
|
||||
}
|
||||
|
||||
// NewRegistry creates a new contribution registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
@@ -171,6 +183,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
|
||||
for _, item := range c.Views {
|
||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
||||
@@ -205,6 +218,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
for _, item := range c.OpenProviders {
|
||||
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
|
||||
}
|
||||
for _, item := range c.WorkspaceItems {
|
||||
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes all contributions from a plugin.
|
||||
@@ -223,6 +239,7 @@ func (r *Registry) Unregister(pluginID string) {
|
||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
}
|
||||
|
||||
// Getters — sorted for deterministic display.
|
||||
@@ -304,6 +321,20 @@ func (r *Registry) OpenProviders() []ContributionOpenProvider {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]ContributionWorkspaceItem, len(r.workspaceItems))
|
||||
copy(result, r.workspaceItems)
|
||||
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 {
|
||||
@@ -405,3 +436,13 @@ func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []Co
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeWorkspaceItems(items []ContributionWorkspaceItem, pluginID string) []ContributionWorkspaceItem {
|
||||
var result []ContributionWorkspaceItem
|
||||
for _, item := range items {
|
||||
if item.PluginID != pluginID {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ type Contributions struct {
|
||||
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
|
||||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
|
||||
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionView represents a view contribution.
|
||||
@@ -162,6 +163,14 @@ type ContributionOpenProvider struct {
|
||||
Supports []OpenProviderSupport `json:"supports"`
|
||||
}
|
||||
|
||||
// ContributionWorkspaceItem represents a workspace tool contribution.
|
||||
type ContributionWorkspaceItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// SyncConfig describes plugin sync configuration.
|
||||
type SyncConfig struct {
|
||||
Namespaces []string `json:"namespaces,omitempty"`
|
||||
|
||||
@@ -205,6 +205,187 @@ func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderUsesTextPreference(t *testing.T) {
|
||||
r := NewRouter(Preferences{
|
||||
DefaultTextEditorProvider: "custom.text-editor",
|
||||
})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("official.editor", "builtin.text", 100, "BuiltinText", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{ContextGenericText},
|
||||
}),
|
||||
provider("custom.editor", "custom.text-editor", 10, "CustomText", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{ContextGenericText},
|
||||
}),
|
||||
}
|
||||
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/todo.txt",
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "custom.text-editor" {
|
||||
t.Fatalf("provider = %q, want custom.text-editor", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderUsesMarkdownPreference(t *testing.T) {
|
||||
r := NewRouter(Preferences{
|
||||
DefaultMarkdownEditorProvider: "community.markdown-editor",
|
||||
})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("official.editor", "builtin.markdown", 100, "BuiltinMarkdown", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{ContextGenericMarkdown},
|
||||
}),
|
||||
provider("community.editor", "community.markdown-editor", 10, "CommunityMarkdown", 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.Item.ID != "community.markdown-editor" {
|
||||
t.Fatalf("provider = %q, want community.markdown-editor", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderMatchesMime(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("image.plugin", "image.viewer", 10, "ImageViewer", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Mime: []string{"image/png", "image/jpeg"},
|
||||
}),
|
||||
}
|
||||
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Photos/screenshot.png",
|
||||
Extension: ".png",
|
||||
Mime: "image/png",
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "image.viewer" {
|
||||
t.Fatalf("provider = %q, want image.viewer", selected.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderExtensionCaseInsensitive(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("editor.plugin", "md.editor", 10, "MDEditor", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
}),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ext string
|
||||
path string
|
||||
}{
|
||||
{"uppercase .MD", ".MD", "Docs/README.MD"},
|
||||
{"mixed case .Md", ".Md", "Docs/Notes.Md"},
|
||||
{"lowercase .md", ".md", "Docs/readme.md"},
|
||||
{"markdown extension uppercase", ".MD", "Docs/guide.MD"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: tt.path,
|
||||
Extension: tt.ext,
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "md.editor" {
|
||||
t.Fatalf("provider = %q, want md.editor for ext %s", selected.Item.ID, tt.ext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectProviderMultipleSupportsEntries(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
providers := []contribution.ContributionOpenProvider{
|
||||
provider("editor.plugin", "multi.editor", 10, "MultiEditor", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
Contexts: []string{ContextGenericMarkdown},
|
||||
}, plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".txt"},
|
||||
Contexts: []string{ContextGenericText},
|
||||
}),
|
||||
}
|
||||
|
||||
t.Run("matches markdown entry", func(t *testing.T) {
|
||||
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.Item.ID != "multi.editor" {
|
||||
t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matches text entry", func(t *testing.T) {
|
||||
selected, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "vault-file",
|
||||
Path: "Docs/todo.txt",
|
||||
Mode: "view",
|
||||
}, providers)
|
||||
if err != nil {
|
||||
t.Fatalf("SelectProvider: %v", err)
|
||||
}
|
||||
if selected.Item.ID != "multi.editor" {
|
||||
t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectProviderKindMismatch(t *testing.T) {
|
||||
r := NewRouter(Preferences{})
|
||||
_, err := r.SelectProvider(OpenResourceRequest{
|
||||
Kind: "http-url",
|
||||
Path: "https://example.com/file.md",
|
||||
Mode: "view",
|
||||
}, []contribution.ContributionOpenProvider{
|
||||
provider("editor.plugin", "vault.editor", 10, "VaultEditor", plugin.OpenProviderSupport{
|
||||
Kind: "vault-file",
|
||||
Extensions: []string{".md"},
|
||||
}),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no provider for http-url kind with vault-file provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineContextName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user