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
+257
View File
@@ -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
}
}
+244
View File
@@ -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)
}
})
}
}