Implement milestone 6b workbench routing skeleton
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user