feat: add workspace templates and tab visibility

This commit is contained in:
2026-07-10 21:32:48 +08:00
parent 2007b909d1
commit e412cfc45a
14 changed files with 739 additions and 67 deletions
+8
View File
@@ -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 {
+19
View File
@@ -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)
+159 -25
View File
@@ -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 {
+42
View File
@@ -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) {