feat: add workspace templates and tab visibility
This commit is contained in:
@@ -1735,6 +1735,14 @@ func (a *App) ListWorkspaces() ([]workspace.Workspace, string) {
|
||||
return workspaces, ""
|
||||
}
|
||||
|
||||
// ListWorkspaceTemplates returns selectable built-in workspace templates.
|
||||
func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
|
||||
if a.workspace == nil {
|
||||
return nil, "workspace not initialized"
|
||||
}
|
||||
return a.workspace.ListWorkspaceTemplates(), ""
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a top-level physical workspace folder.
|
||||
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
|
||||
@@ -1448,6 +1448,25 @@ func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceAPIListsSelectableTemplates(t *testing.T) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
if err := app.workspace.Load(); err != nil {
|
||||
t.Fatalf("workspace Load: %v", err)
|
||||
}
|
||||
|
||||
templates, errStr := app.ListWorkspaceTemplates()
|
||||
if errStr != "" {
|
||||
t.Fatalf("ListWorkspaceTemplates: %s", errStr)
|
||||
}
|
||||
if len(templates) != 5 {
|
||||
t.Fatalf("templates = %+v, want 5 selectable templates", templates)
|
||||
}
|
||||
if templates[0].ID != "default" || templates[1].ID != "project" || templates[4].ID != "minimal" {
|
||||
t.Fatalf("template order = %+v", templates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
|
||||
@@ -46,10 +46,21 @@ type Workspace struct {
|
||||
|
||||
// TemplateSnapshot is copied into workspace metadata when a template is applied.
|
||||
type TemplateSnapshot struct {
|
||||
TemplateID string `json:"templateId"`
|
||||
TemplateName string `json:"templateName"`
|
||||
TemplateVersion int `json:"templateVersion"`
|
||||
AppliedAt string `json:"appliedAt"`
|
||||
TemplateID string `json:"templateId"`
|
||||
TemplateName string `json:"templateName"`
|
||||
TemplateVersion int `json:"templateVersion"`
|
||||
AppliedAt string `json:"appliedAt"`
|
||||
WorkspaceTools []string `json:"workspaceTools,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceTemplate describes a selectable built-in template without exposing
|
||||
// its filesystem implementation details.
|
||||
type WorkspaceTemplate struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version int `json:"version"`
|
||||
WorkspaceTools []string `json:"workspaceTools"`
|
||||
}
|
||||
|
||||
// Metadata stores semantic workspace metadata that is not the source of truth
|
||||
@@ -59,6 +70,7 @@ type Metadata struct {
|
||||
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
Folders map[string]string `json:"folders,omitempty"`
|
||||
WorkspaceTools []string `json:"workspaceTools,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
@@ -102,35 +114,127 @@ type WorkspaceTree struct {
|
||||
}
|
||||
|
||||
type templateDefinition struct {
|
||||
ID string
|
||||
Name string
|
||||
Version int
|
||||
Features map[string]bool
|
||||
Folders map[string]string
|
||||
Files map[string]string
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Version int
|
||||
Features map[string]bool
|
||||
Folders map[string]string
|
||||
Files map[string]string
|
||||
WorkspaceTools []string
|
||||
Selectable bool
|
||||
Order int
|
||||
}
|
||||
|
||||
var builtInTemplates = map[string]templateDefinition{
|
||||
"default": {
|
||||
ID: "default",
|
||||
Name: "Default Workspace",
|
||||
Version: 1,
|
||||
ID: "default",
|
||||
Name: "General",
|
||||
Description: "Everyday workspace with notes, files, journal, activity, and browser captures.",
|
||||
Version: 2,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
"secrets": false,
|
||||
"activity": false,
|
||||
"files": true,
|
||||
"notes": true,
|
||||
"secrets": false,
|
||||
"activity": true,
|
||||
"journal": true,
|
||||
"browser-inbox": true,
|
||||
},
|
||||
Folders: map[string]string{
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal", "verstak.activity", "verstak.browser-inbox"},
|
||||
Selectable: true,
|
||||
Order: 10,
|
||||
},
|
||||
"project": {
|
||||
ID: "project",
|
||||
Name: "Project",
|
||||
Description: "Project planning with todos, journal, activity, and browser captures.",
|
||||
Version: 1,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
"todo": true,
|
||||
"journal": true,
|
||||
"activity": true,
|
||||
"browser-inbox": true,
|
||||
},
|
||||
Folders: map[string]string{
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.todo", "verstak.journal", "verstak.activity", "verstak.browser-inbox"},
|
||||
Selectable: true,
|
||||
Order: 20,
|
||||
},
|
||||
"writing": {
|
||||
ID: "writing",
|
||||
Name: "Writing",
|
||||
Description: "Focused notes, files, and journal workspace for documentation and writing.",
|
||||
Version: 1,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
"journal": true,
|
||||
},
|
||||
Folders: map[string]string{
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal"},
|
||||
Selectable: true,
|
||||
Order: 30,
|
||||
},
|
||||
"admin": {
|
||||
ID: "admin",
|
||||
Name: "Admin",
|
||||
Description: "Infrastructure workspace with secrets, todos, and journal.",
|
||||
Version: 1,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
"secrets": true,
|
||||
"todo": true,
|
||||
"journal": true,
|
||||
},
|
||||
Folders: map[string]string{
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
"secrets": "Secrets",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets", "verstak.todo", "verstak.journal"},
|
||||
Selectable: true,
|
||||
Order: 40,
|
||||
},
|
||||
"minimal": {
|
||||
ID: "minimal",
|
||||
Name: "Minimal",
|
||||
Description: "Only notes and files for a lightweight workspace.",
|
||||
Version: 1,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
},
|
||||
Folders: map[string]string{
|
||||
"notes": "Notes",
|
||||
"files": "Files",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files"},
|
||||
Selectable: true,
|
||||
Order: 50,
|
||||
},
|
||||
"client-project": {
|
||||
ID: "client-project",
|
||||
Name: "Client Project",
|
||||
Version: 1,
|
||||
ID: "client-project",
|
||||
Name: "Client Project",
|
||||
Description: "Legacy client project template retained for existing integrations.",
|
||||
Version: 1,
|
||||
Features: map[string]bool{
|
||||
"files": true,
|
||||
"notes": true,
|
||||
@@ -142,7 +246,10 @@ var builtInTemplates = map[string]templateDefinition{
|
||||
"files": "Files",
|
||||
"secrets": "Secrets",
|
||||
},
|
||||
Files: map[string]string{},
|
||||
Files: map[string]string{},
|
||||
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets"},
|
||||
Selectable: false,
|
||||
Order: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -243,10 +350,12 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
TemplateName: template.Name,
|
||||
TemplateVersion: template.Version,
|
||||
AppliedAt: now,
|
||||
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
|
||||
},
|
||||
Features: cloneBoolMap(template.Features),
|
||||
Folders: cloneStringMap(template.Folders),
|
||||
UpdatedAt: now,
|
||||
Features: cloneBoolMap(template.Features),
|
||||
Folders: cloneStringMap(template.Folders),
|
||||
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.writeMetadata(name, meta); err != nil {
|
||||
return Workspace{}, err
|
||||
@@ -256,6 +365,27 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
return Workspace{Name: name, RootPath: name}, nil
|
||||
}
|
||||
|
||||
// ListWorkspaceTemplates returns selectable built-ins in their presentation order.
|
||||
func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate {
|
||||
templates := make([]WorkspaceTemplate, 0, len(builtInTemplates))
|
||||
for _, template := range builtInTemplates {
|
||||
if !template.Selectable {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, WorkspaceTemplate{
|
||||
ID: template.ID,
|
||||
Name: template.Name,
|
||||
Description: template.Description,
|
||||
Version: template.Version,
|
||||
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(templates, func(i, j int) bool {
|
||||
return builtInTemplates[templates[i].ID].Order < builtInTemplates[templates[j].ID].Order
|
||||
})
|
||||
return templates
|
||||
}
|
||||
|
||||
// RenameWorkspace physically renames a top-level workspace folder and metadata key.
|
||||
func (m *Manager) RenameWorkspace(oldName, newName string) error {
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
@@ -748,6 +878,10 @@ func cloneStringMap(src map[string]string) map[string]string {
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneStringSlice(src []string) []string {
|
||||
return append([]string(nil), src...)
|
||||
}
|
||||
|
||||
func hasAnyTrueFeature(features map[string]bool) bool {
|
||||
for _, enabled := range features {
|
||||
if enabled {
|
||||
|
||||
@@ -120,6 +120,45 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspaceTemplatesExposesSelectableBuiltIns(t *testing.T) {
|
||||
m := NewManager(newVaultDir(t))
|
||||
templates := m.ListWorkspaceTemplates()
|
||||
|
||||
wantIDs := []string{"default", "project", "writing", "admin", "minimal"}
|
||||
if len(templates) != len(wantIDs) {
|
||||
t.Fatalf("templates = %+v, want %d selectable built-ins", templates, len(wantIDs))
|
||||
}
|
||||
gotIDs := make([]string, 0, len(templates))
|
||||
for _, template := range templates {
|
||||
gotIDs = append(gotIDs, template.ID)
|
||||
if template.Name == "" || template.Description == "" || template.Version == 0 || len(template.WorkspaceTools) == 0 {
|
||||
t.Fatalf("template is missing presentation or workspace tool data: %+v", template)
|
||||
}
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
||||
t.Fatalf("template ids = %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceStoresWorkspaceToolSnapshot(t *testing.T) {
|
||||
m := NewManager(newVaultDir(t))
|
||||
if _, err := m.CreateWorkspace("Minimal", "minimal"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
|
||||
meta, err := m.GetWorkspaceMetadata("Minimal")
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceMetadata: %v", err)
|
||||
}
|
||||
wantTools := []string{"verstak.notes", "verstak.files"}
|
||||
if strings.Join(meta.WorkspaceTools, ",") != strings.Join(wantTools, ",") {
|
||||
t.Fatalf("metadata workspace tools = %v, want %v", meta.WorkspaceTools, wantTools)
|
||||
}
|
||||
if meta.CreatedFromTemplate == nil || strings.Join(meta.CreatedFromTemplate.WorkspaceTools, ",") != strings.Join(wantTools, ",") {
|
||||
t.Fatalf("template snapshot workspace tools = %+v, want %v", meta.CreatedFromTemplate, wantTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
@@ -136,6 +175,9 @@ func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
|
||||
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
|
||||
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
|
||||
}
|
||||
if len(meta.WorkspaceTools) == 0 || len(meta.CreatedFromTemplate.WorkspaceTools) == 0 {
|
||||
t.Fatalf("workspace tool snapshot not preserved after registry clear: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user