Вложенные Дела, folder metadata, плагин workspace-folders
- Nested workspace model: маркеры .verstak/workspace.json на любой глубине - Folder metadata API: иконка, цвет, порядок (.verstak/folder-metadata/) - MoveWorkspace, GetFolderMetadata, SetFolderMetadata Wails биндинги - Contribution point workspaceTree — singleton в registry - Sidebar.svelte монтирует PluginBundleHost при наличии workspaceTree - VerstakPluginAPI: workspaces.* и folders.* методы - WorkspaceTree.svelte: рекурсивное дерево, выбор родительской папки - i18n: строки EN+RU, release notes v0.1.0-alpha.8
This commit is contained in:
+60
-8
@@ -979,6 +979,13 @@ type ContributionSummary struct {
|
||||
FileActions []FlatAction `json:"fileActions"`
|
||||
NoteActions []FlatAction `json:"noteActions"`
|
||||
ContextMenuEntries []FlatContextMenuEntry `json:"contextMenuEntries"`
|
||||
WorkspaceTree *FlatWorkspaceTree `json:"workspaceTree,omitempty"`
|
||||
}
|
||||
|
||||
// FlatWorkspaceTree is the summary for the workspaceTree singleton contribution.
|
||||
type FlatWorkspaceTree struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||
@@ -1053,7 +1060,12 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||
for i, v := range regContextMenus {
|
||||
contextMenus[i] = FlatContextMenuEntry{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Context: v.Item.Context, Group: v.Item.Group, Capability: v.Item.Capability, Handler: v.Item.Handler}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems, FileActions: fileActions, NoteActions: noteActions, ContextMenuEntries: contextMenus}
|
||||
var wsTree *FlatWorkspaceTree
|
||||
regWSTree := r.WorkspaceTree()
|
||||
if regWSTree != nil {
|
||||
wsTree = &FlatWorkspaceTree{PluginID: regWSTree.PluginID, Component: regWSTree.Component}
|
||||
}
|
||||
return ContributionSummary{Views: views, Commands: cmds, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems, FileActions: fileActions, NoteActions: noteActions, ContextMenuEntries: contextMenus, WorkspaceTree: wsTree}
|
||||
}
|
||||
|
||||
// GetContributions returns all registered contributions flattened for the frontend.
|
||||
@@ -2591,7 +2603,7 @@ func (a *App) UpdateWorkspaceMetadata(name string, patch workspace.MetadataPatch
|
||||
return meta, ""
|
||||
}
|
||||
|
||||
// GetCurrentWorkspace returns the currently selected top-level workspace.
|
||||
// GetCurrentWorkspace returns the currently selected workspace.
|
||||
func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
if a.workspace == nil {
|
||||
return map[string]interface{}{"status": "not initialized"}
|
||||
@@ -2600,7 +2612,7 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(node.Name)
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(node.Path)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
@@ -2609,25 +2621,65 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
"path": node.Path,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCurrentWorkspace stores the selected top-level workspace name as UI state.
|
||||
func (a *App) SetCurrentWorkspace(name string) string {
|
||||
// SetCurrentWorkspace stores the selected workspace path as UI state.
|
||||
func (a *App) SetCurrentWorkspace(path string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.SetCurrentNode(name); err != nil {
|
||||
if err := a.workspace.SetCurrentNode(path); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
node, err := a.workspace.GetCurrentNode()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceSelectedEventName, map[string]interface{}{
|
||||
"operation": "select",
|
||||
"workspaceRootPath": name,
|
||||
"workspaceName": name,
|
||||
"workspaceRootPath": node.RootPath,
|
||||
"workspaceName": node.Name,
|
||||
"workspacePath": node.Path,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetFolderMetadata returns stored metadata for a plain vault folder.
|
||||
func (a *App) GetFolderMetadata(path string) (workspace.FolderMetadata, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.FolderMetadata{}, "workspace not initialized"
|
||||
}
|
||||
meta, err := a.workspace.GetFolderMetadata(path)
|
||||
if err != nil {
|
||||
return workspace.FolderMetadata{}, err.Error()
|
||||
}
|
||||
return meta, ""
|
||||
}
|
||||
|
||||
// SetFolderMetadata updates metadata for a plain vault folder.
|
||||
func (a *App) SetFolderMetadata(path string, meta workspace.FolderMetadata) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.SetFolderMetadata(path, meta); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MoveWorkspace moves a workspace to another parent folder.
|
||||
func (a *App) MoveWorkspace(id, newParentID string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.MoveNode(id, newParentID); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer ListWorkspaces.
|
||||
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||
|
||||
@@ -2624,9 +2624,6 @@ func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
|
||||
if len(nodes) == 0 {
|
||||
t.Fatal("workspace nodes should not be empty")
|
||||
}
|
||||
if nodes[0].Path != "" {
|
||||
t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0])
|
||||
}
|
||||
if !app.capRegistry.Has("verstak/core/workspace/v1") {
|
||||
t.Fatal("workspace capability should be registered after SetCurrentVault")
|
||||
}
|
||||
@@ -2905,11 +2902,11 @@ func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
|
||||
}
|
||||
|
||||
errStr := app.MoveWorkspaceNode("Project", "Test")
|
||||
if errStr == "" || !strings.Contains(errStr, "top-level only") {
|
||||
t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr)
|
||||
if errStr == "" || !strings.Contains(errStr, "parent-is-workspace") {
|
||||
t.Fatalf("MoveWorkspaceNode error = %q, want parent-is-workspace", errStr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) {
|
||||
t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err)
|
||||
t.Fatalf("MoveWorkspaceNode created nested workspace inside another workspace, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ type Registry struct {
|
||||
statusBarItems []ContributionStatusBarItem
|
||||
openProviders []ContributionOpenProvider
|
||||
workspaceItems []ContributionWorkspaceItem
|
||||
workspaceTree *ContributionWorkspaceTree
|
||||
}
|
||||
|
||||
// ContributionWorkspaceTree is a singleton contribution for replacing the Deal tree.
|
||||
type ContributionWorkspaceTree struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// ContributionPointType defines the type of contribution point.
|
||||
@@ -42,6 +49,7 @@ const (
|
||||
PointStatusBar ContributionPointType = "statusBarItems"
|
||||
PointOpenProviders ContributionPointType = "openProviders"
|
||||
PointWorkspaceItems ContributionPointType = "workspaceItems"
|
||||
PointWorkspaceTree ContributionPointType = "workspaceTree"
|
||||
)
|
||||
|
||||
// ListByPoint returns all contributions for a given point type.
|
||||
@@ -184,6 +192,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
r.workspaceTree = nil
|
||||
|
||||
for _, item := range c.Views {
|
||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
||||
@@ -221,6 +230,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||
for _, item := range c.WorkspaceItems {
|
||||
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
|
||||
}
|
||||
if c.WorkspaceTree != nil && r.workspaceTree == nil {
|
||||
r.workspaceTree = &ContributionWorkspaceTree{PluginID: pluginID, Component: c.WorkspaceTree.Component}
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes all contributions from a plugin.
|
||||
@@ -240,6 +252,9 @@ func (r *Registry) Unregister(pluginID string) {
|
||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
|
||||
if r.workspaceTree != nil && r.workspaceTree.PluginID == pluginID {
|
||||
r.workspaceTree = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Getters — sorted for deterministic display.
|
||||
@@ -367,6 +382,13 @@ func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
|
||||
return result
|
||||
}
|
||||
|
||||
// WorkspaceTree returns the singleton workspaceTree contribution, or nil.
|
||||
func (r *Registry) WorkspaceTree() *ContributionWorkspaceTree {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.workspaceTree
|
||||
}
|
||||
|
||||
// ─── Remove helpers ─────────────────────────────────────────
|
||||
|
||||
func removeViews(items []ContributionView, pluginID string) []ContributionView {
|
||||
|
||||
@@ -77,6 +77,12 @@ type Contributions struct {
|
||||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
|
||||
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
|
||||
WorkspaceTree *ContributionWorkspaceTree `json:"workspaceTree,omitempty"`
|
||||
}
|
||||
|
||||
// ContributionWorkspaceTree represents a singleton workspaceTree contribution.
|
||||
type ContributionWorkspaceTree struct {
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// ContributionView represents a view contribution.
|
||||
|
||||
+1110
-763
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@ import (
|
||||
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, ".git"))
|
||||
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
|
||||
@@ -36,6 +38,61 @@ func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspacesIncludesNestedWorkspaces(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Romashka"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Romashka"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Alpha"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Alpha"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Personal"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Personal"))
|
||||
|
||||
m := NewManager(vaultDir)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
workspaces, err := m.ListWorkspaces()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces: %v", err)
|
||||
}
|
||||
|
||||
if len(workspaces) != 3 {
|
||||
t.Fatalf("workspaces = %d, want 3", len(workspaces))
|
||||
}
|
||||
paths := make([]string, len(workspaces))
|
||||
for i, ws := range workspaces {
|
||||
paths[i] = ws.Path
|
||||
}
|
||||
wantPaths := []string{"Clients/Alpha", "Clients/Romashka", "Personal"}
|
||||
if strings.Join(paths, ",") != strings.Join(wantPaths, ",") {
|
||||
t.Fatalf("paths = %v, want %v", paths, wantPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceNested(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
|
||||
// Create parent folder first
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
|
||||
ws, err := m.CreateWorkspace("Clients/Project", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace nested: %v", err)
|
||||
}
|
||||
if ws.Path != "Clients/Project" {
|
||||
t.Fatalf("workspace path = %q, want Clients/Project", ws.Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project")); err != nil {
|
||||
t.Fatalf("workspace folder missing: %v", err)
|
||||
}
|
||||
// Verify marker
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project", ".verstak", "workspace.json")); err != nil {
|
||||
t.Fatalf("marker missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs extra privileges on Windows")
|
||||
@@ -556,23 +613,24 @@ func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
|
||||
func TestInvalidWorkspacePathsRejected(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
|
||||
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
|
||||
for _, name := range names {
|
||||
if _, err := m.CreateWorkspace(name, ""); err == nil {
|
||||
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
|
||||
paths := []string{"", " ", `A\B`, "/abs", `C:\abs`, "..", "a/../b", "bad\x00name", ".verstak", ".Verstak", ".git"}
|
||||
for _, path := range paths {
|
||||
if _, err := m.CreateWorkspace(path, ""); err == nil {
|
||||
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid path error", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
|
||||
func TestCompatibilityTreeIncludesFoldersAndNestedWorkspaces(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
|
||||
|
||||
m := NewManager(vaultDir)
|
||||
if err := m.Load(); err != nil {
|
||||
@@ -582,34 +640,43 @@ func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
|
||||
if len(tree.Nodes) != 2 {
|
||||
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
|
||||
}
|
||||
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" {
|
||||
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
|
||||
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" {
|
||||
t.Fatalf("first compatibility node = %+v, want workspace node", tree.Nodes[0])
|
||||
}
|
||||
for _, node := range tree.Nodes {
|
||||
if node.ParentID != "" {
|
||||
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
|
||||
}
|
||||
if node.ID == "Nested" || node.Title == "Nested" {
|
||||
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes)
|
||||
t.Fatalf("nested folders without markers must not become workspace nodes: %+v", tree.Nodes)
|
||||
}
|
||||
}
|
||||
// Workspace nodes should NOT have ParentID for root-level ones
|
||||
for _, node := range tree.Nodes {
|
||||
if node.Type == TypeSpace && node.ID == "Project" && node.ParentID != "" {
|
||||
t.Fatalf("root workspace should have empty ParentID: %+v", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
|
||||
func TestMoveNodeCreatesNestedWorkspaceModel(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Project"))
|
||||
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Test"))
|
||||
|
||||
m := NewManager(vaultDir)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// Move workspace into Test folder (which is a plain folder without marker)
|
||||
err := m.MoveNode("Project", "Test")
|
||||
if err == nil || !strings.Contains(err.Error(), "top-level only") {
|
||||
t.Fatalf("MoveNode error = %v, want top-level only", err)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveNode error = %v, want success", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
|
||||
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); statErr != nil {
|
||||
t.Fatalf("MoveNode did not create nested folder, stat err=%v", statErr)
|
||||
}
|
||||
// Verify marker moved
|
||||
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project", ".verstak", "workspace.json")); statErr != nil {
|
||||
t.Fatalf("marker not in new location: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,6 +703,22 @@ func TestMetadataFileShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteWorkspaceMarker(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
markerPath := filepath.Join(dir, ".verstak", "workspace.json")
|
||||
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(markerPath), err)
|
||||
}
|
||||
marker := workspaceIdentityMarker{WorkspaceID: uuid.NewString()}
|
||||
data, err := json.Marshal(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal marker: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(markerPath, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", markerPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
vaultDir := filepath.Join(t.TempDir(), "vault")
|
||||
|
||||
Reference in New Issue
Block a user