795 lines
21 KiB
Go
795 lines
21 KiB
Go
// Package workspace provides the semantic workspace lifecycle service.
|
|
//
|
|
// A workspace is a top-level physical folder directly under the vault root.
|
|
// The filesystem is the source of truth for workspace existence and listing.
|
|
// Metadata under .verstak stores UI state and creation snapshots only.
|
|
package workspace
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// NodeType is retained for compatibility with the current shell API.
|
|
type NodeType string
|
|
|
|
const (
|
|
TypeSpace NodeType = "space"
|
|
TypeCase NodeType = "case"
|
|
TypeFolder NodeType = "folder"
|
|
)
|
|
|
|
// NodeStatus is retained for compatibility with the current shell API.
|
|
type NodeStatus string
|
|
|
|
const (
|
|
StatusActive NodeStatus = "active"
|
|
StatusSleeping NodeStatus = "sleeping"
|
|
StatusArchived NodeStatus = "archived"
|
|
)
|
|
|
|
// Workspace is a physical top-level workspace folder.
|
|
type Workspace struct {
|
|
Name string `json:"name"`
|
|
RootPath string `json:"rootPath"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// Metadata stores semantic workspace metadata that is not the source of truth
|
|
// for whether the workspace exists.
|
|
type Metadata struct {
|
|
WorkspaceName string `json:"workspaceName"`
|
|
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
|
|
Features map[string]bool `json:"features,omitempty"`
|
|
Folders map[string]string `json:"folders,omitempty"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
// MetadataPatch updates metadata fields without replacing unspecified fields.
|
|
type MetadataPatch struct {
|
|
Features map[string]bool `json:"features,omitempty"`
|
|
Folders map[string]string `json:"folders,omitempty"`
|
|
}
|
|
|
|
// TrashResult describes a workspace moved into the internal trash area.
|
|
type TrashResult struct {
|
|
OriginalPath string `json:"originalPath"`
|
|
TrashPath string `json:"trashPath"`
|
|
TrashID string `json:"trashId"`
|
|
DeletedAt string `json:"deletedAt"`
|
|
}
|
|
|
|
// WorkspaceNode is a compatibility shell view of a top-level workspace.
|
|
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
|
|
type WorkspaceNode struct {
|
|
ID string `json:"id"`
|
|
ParentID string `json:"parentId,omitempty"`
|
|
Type NodeType `json:"type"`
|
|
Title string `json:"title"`
|
|
Name string `json:"name,omitempty"`
|
|
RootPath string `json:"rootPath,omitempty"`
|
|
Path string `json:"-"`
|
|
Status NodeStatus `json:"status"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Order int `json:"order"`
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
// WorkspaceTree is a compatibility flat list, derived from top-level folders.
|
|
type WorkspaceTree struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Nodes []WorkspaceNode `json:"nodes"`
|
|
CurrentNodeID string `json:"currentNodeId,omitempty"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type templateDefinition struct {
|
|
ID string
|
|
Name string
|
|
Version int
|
|
Features map[string]bool
|
|
Folders map[string]string
|
|
Files map[string]string
|
|
}
|
|
|
|
var builtInTemplates = map[string]templateDefinition{
|
|
"default": {
|
|
ID: "default",
|
|
Name: "Default Workspace",
|
|
Version: 1,
|
|
Features: map[string]bool{
|
|
"files": true,
|
|
"notes": true,
|
|
"secrets": false,
|
|
"activity": false,
|
|
},
|
|
Folders: map[string]string{
|
|
"notes": "Notes",
|
|
"files": "Files",
|
|
},
|
|
Files: map[string]string{
|
|
"Notes/Overview.md": "# Overview\n",
|
|
},
|
|
},
|
|
"client-project": {
|
|
ID: "client-project",
|
|
Name: "Client Project",
|
|
Version: 1,
|
|
Features: map[string]bool{
|
|
"files": true,
|
|
"notes": true,
|
|
"secrets": true,
|
|
"activity": false,
|
|
},
|
|
Folders: map[string]string{
|
|
"notes": "Notes",
|
|
"files": "Files",
|
|
"secrets": "Secrets",
|
|
},
|
|
Files: map[string]string{
|
|
"Notes/Overview.md": "# Overview\n",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Manager provides workspace operations for one vault.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
vaultDir string
|
|
initialized bool
|
|
currentWorkspaceName string
|
|
}
|
|
|
|
// NewManager creates a workspace manager for the given vault directory.
|
|
func NewManager(vaultDir string) *Manager {
|
|
return &Manager{vaultDir: vaultDir}
|
|
}
|
|
|
|
// Load initializes the manager without creating or migrating workspace folders.
|
|
func (m *Manager) Load() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.initialized = true
|
|
m.currentWorkspaceName = m.readSelectedWorkspaceLocked()
|
|
return nil
|
|
}
|
|
|
|
// IsInitialized returns true after Load has been called.
|
|
func (m *Manager) IsInitialized() bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.initialized
|
|
}
|
|
|
|
// ListWorkspaces returns top-level physical workspace folders from the vault.
|
|
func (m *Manager) ListWorkspaces() ([]Workspace, error) {
|
|
entries, err := os.ReadDir(m.vaultDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
workspaces := make([]Workspace, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if isReservedWorkspaceName(name) {
|
|
continue
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
continue
|
|
}
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
workspaces = append(workspaces, Workspace{Name: name, RootPath: name})
|
|
}
|
|
sort.Slice(workspaces, func(i, j int) bool {
|
|
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
|
|
})
|
|
return workspaces, nil
|
|
}
|
|
|
|
// CreateWorkspace creates a top-level workspace folder and applies a template once.
|
|
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
|
name = strings.TrimSpace(name)
|
|
if err := validateWorkspaceName(name); err != nil {
|
|
return Workspace{}, err
|
|
}
|
|
if templateID == "" {
|
|
templateID = "default"
|
|
}
|
|
template, ok := builtInTemplates[templateID]
|
|
if !ok {
|
|
return Workspace{}, fmt.Errorf("template-not-found: %s", templateID)
|
|
}
|
|
|
|
full := filepath.Join(m.vaultDir, name)
|
|
if _, err := os.Lstat(full); err == nil {
|
|
return Workspace{}, fmt.Errorf("conflict: %s", name)
|
|
} else if !os.IsNotExist(err) {
|
|
return Workspace{}, err
|
|
}
|
|
if err := os.Mkdir(full, 0o755); err != nil {
|
|
return Workspace{}, err
|
|
}
|
|
created := true
|
|
defer func() {
|
|
if created {
|
|
_ = os.RemoveAll(full)
|
|
}
|
|
}()
|
|
|
|
if err := applyTemplate(full, template); err != nil {
|
|
return Workspace{}, err
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
meta := Metadata{
|
|
WorkspaceName: name,
|
|
CreatedFromTemplate: &TemplateSnapshot{
|
|
TemplateID: template.ID,
|
|
TemplateName: template.Name,
|
|
TemplateVersion: template.Version,
|
|
AppliedAt: now,
|
|
},
|
|
Features: cloneBoolMap(template.Features),
|
|
Folders: cloneStringMap(template.Folders),
|
|
UpdatedAt: now,
|
|
}
|
|
if err := m.writeMetadata(name, meta); err != nil {
|
|
return Workspace{}, err
|
|
}
|
|
|
|
created = false
|
|
return Workspace{Name: name, RootPath: name}, nil
|
|
}
|
|
|
|
// RenameWorkspace physically renames a top-level workspace folder and metadata key.
|
|
func (m *Manager) RenameWorkspace(oldName, newName string) error {
|
|
oldName = strings.TrimSpace(oldName)
|
|
newName = strings.TrimSpace(newName)
|
|
if err := validateWorkspaceName(oldName); err != nil {
|
|
return err
|
|
}
|
|
if err := validateWorkspaceName(newName); err != nil {
|
|
return err
|
|
}
|
|
oldFull := filepath.Join(m.vaultDir, oldName)
|
|
newFull := filepath.Join(m.vaultDir, newName)
|
|
if err := ensureExistingWorkspaceDir(oldFull, oldName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(newFull); err == nil {
|
|
return fmt.Errorf("conflict: %s", newName)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
if err := os.Rename(oldFull, newFull); err != nil {
|
|
return err
|
|
}
|
|
renamedFolder := true
|
|
defer func() {
|
|
if renamedFolder {
|
|
_ = os.Rename(newFull, oldFull)
|
|
}
|
|
}()
|
|
|
|
oldMetaPath := m.metadataPath(oldName)
|
|
if data, err := os.ReadFile(oldMetaPath); err == nil {
|
|
var meta Metadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return err
|
|
}
|
|
meta.WorkspaceName = newName
|
|
meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
if err := m.writeMetadata(newName, meta); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(oldMetaPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.currentWorkspaceName == oldName {
|
|
m.currentWorkspaceName = newName
|
|
_ = m.writeUIStateLocked()
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
renamedFolder = false
|
|
return nil
|
|
}
|
|
|
|
// TrashWorkspace moves the whole top-level workspace folder to internal trash.
|
|
func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
|
name = strings.TrimSpace(name)
|
|
if err := validateWorkspaceName(name); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
full := filepath.Join(m.vaultDir, name)
|
|
if err := ensureExistingWorkspaceDir(full, name); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
|
|
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
|
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
|
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "workspaces", trashID, name))
|
|
trashFull := filepath.Join(m.vaultDir, filepath.FromSlash(trashRel))
|
|
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
if err := os.Rename(full, trashFull); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
|
|
result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
|
|
trashMeta := map[string]string{
|
|
"originalPath": name,
|
|
"trashPath": trashRel,
|
|
"trashId": trashID,
|
|
"deletedAt": deletedAt,
|
|
"originalType": "folder",
|
|
"basename": name,
|
|
"type": "workspace",
|
|
}
|
|
data, err := json.MarshalIndent(trashMeta, "", " ")
|
|
if err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
|
|
if err := os.WriteFile(filepath.Join(trashDir, "metadata.json"), data, 0o644); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
if err := moveIfExists(m.metadataPath(name), filepath.Join(trashDir, "workspace.metadata.json")); err != nil {
|
|
return TrashResult{}, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.currentWorkspaceName == name {
|
|
m.currentWorkspaceName = ""
|
|
_ = m.writeUIStateLocked()
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetWorkspaceMetadata returns stored metadata or safe generic metadata.
|
|
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
|
|
name = strings.TrimSpace(name)
|
|
if err := validateWorkspaceName(name); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
full := filepath.Join(m.vaultDir, name)
|
|
if err := ensureExistingWorkspaceDir(full, name); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
|
|
data, err := os.ReadFile(m.metadataPath(name))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return genericMetadata(name), nil
|
|
}
|
|
return Metadata{}, err
|
|
}
|
|
var meta Metadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
// Workspace identity is the top-level folder name. Stored metadata may be
|
|
// stale after manual edits or old dev snapshots, so normalize the returned
|
|
// presentation name without writing back to disk.
|
|
meta.WorkspaceName = name
|
|
if meta.Features == nil {
|
|
meta.Features = map[string]bool{"files": true}
|
|
}
|
|
if !hasAnyTrueFeature(meta.Features) {
|
|
meta.Features["files"] = true
|
|
}
|
|
if meta.Folders == nil {
|
|
meta.Folders = defaultFolders()
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// UpdateWorkspaceMetadata merges UI/semantic metadata fields for an existing workspace.
|
|
func (m *Manager) UpdateWorkspaceMetadata(name string, patch MetadataPatch) (Metadata, error) {
|
|
meta, err := m.GetWorkspaceMetadata(name)
|
|
if err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
if meta.Features == nil {
|
|
meta.Features = map[string]bool{}
|
|
}
|
|
for k, v := range patch.Features {
|
|
meta.Features[k] = v
|
|
}
|
|
if meta.Folders == nil {
|
|
meta.Folders = map[string]string{}
|
|
}
|
|
for k, v := range patch.Folders {
|
|
meta.Folders[k] = v
|
|
}
|
|
meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
if err := m.writeMetadata(name, meta); err != nil {
|
|
return Metadata{}, err
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// GetTree returns a compatibility flat tree derived from top-level folders.
|
|
func (m *Manager) GetTree() WorkspaceTree {
|
|
workspaces, err := m.ListWorkspaces()
|
|
if err != nil {
|
|
return WorkspaceTree{SchemaVersion: 1, Nodes: []WorkspaceNode{}}
|
|
}
|
|
|
|
m.mu.RLock()
|
|
current := m.currentWorkspaceName
|
|
m.mu.RUnlock()
|
|
if current == "" || !workspaceExists(workspaces, current) {
|
|
if len(workspaces) > 0 {
|
|
current = workspaces[0].Name
|
|
}
|
|
}
|
|
|
|
nodes := make([]WorkspaceNode, 0, len(workspaces))
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
for i, ws := range workspaces {
|
|
nodes = append(nodes, WorkspaceNode{
|
|
ID: ws.Name,
|
|
Type: TypeSpace,
|
|
Title: ws.Name,
|
|
Name: ws.Name,
|
|
RootPath: ws.RootPath,
|
|
Status: StatusActive,
|
|
Order: i,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
return WorkspaceTree{SchemaVersion: 1, Nodes: nodes, CurrentNodeID: current, UpdatedAt: now}
|
|
}
|
|
|
|
// GetNode returns a compatibility node by workspace name.
|
|
func (m *Manager) GetNode(id string) (WorkspaceNode, error) {
|
|
for _, node := range m.GetTree().Nodes {
|
|
if node.ID == id {
|
|
return node, nil
|
|
}
|
|
}
|
|
return WorkspaceNode{}, fmt.Errorf("workspace not found: %s", id)
|
|
}
|
|
|
|
// ListChildren returns no children because workspaces are only top-level folders.
|
|
func (m *Manager) ListChildren(parentID string) []WorkspaceNode {
|
|
if parentID != "" {
|
|
return nil
|
|
}
|
|
return m.GetTree().Nodes
|
|
}
|
|
|
|
// CreateNode is a compatibility wrapper for creating top-level workspaces only.
|
|
func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) {
|
|
if parentID != "" {
|
|
return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only")
|
|
}
|
|
if nodeType != "" && nodeType != TypeSpace {
|
|
return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only")
|
|
}
|
|
ws, err := m.CreateWorkspace(title, "")
|
|
if err != nil {
|
|
return WorkspaceNode{}, err
|
|
}
|
|
return WorkspaceNode{ID: ws.Name, Type: TypeSpace, Title: ws.Name, Name: ws.Name, RootPath: ws.RootPath, Status: StatusActive}, nil
|
|
}
|
|
|
|
// RenameNode is a compatibility wrapper for physical workspace rename.
|
|
func (m *Manager) RenameNode(id, title string) error {
|
|
return m.RenameWorkspace(id, title)
|
|
}
|
|
|
|
// MoveNode is unsupported in the corrected workspace model.
|
|
func (m *Manager) MoveNode(id, newParentID string) error {
|
|
return fmt.Errorf("workspace folders are top-level only")
|
|
}
|
|
|
|
// ArchiveNode is a compatibility wrapper for trashing a workspace.
|
|
func (m *Manager) ArchiveNode(id string) error {
|
|
_, err := m.TrashWorkspace(id)
|
|
return err
|
|
}
|
|
|
|
// SetCurrentNode stores UI selection only.
|
|
func (m *Manager) SetCurrentNode(id string) error {
|
|
if err := validateWorkspaceName(id); err != nil {
|
|
return err
|
|
}
|
|
workspaces, err := m.ListWorkspaces()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !workspaceExists(workspaces, id) {
|
|
return fmt.Errorf("workspace not found: %s", id)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.currentWorkspaceName = id
|
|
return m.writeUIStateLocked()
|
|
}
|
|
|
|
// GetCurrentNode returns the currently selected compatibility node.
|
|
func (m *Manager) GetCurrentNode() (WorkspaceNode, error) {
|
|
tree := m.GetTree()
|
|
if tree.CurrentNodeID == "" {
|
|
return WorkspaceNode{}, fmt.Errorf("no current workspace")
|
|
}
|
|
for _, node := range tree.Nodes {
|
|
if node.ID == tree.CurrentNodeID {
|
|
return node, nil
|
|
}
|
|
}
|
|
return WorkspaceNode{}, fmt.Errorf("current workspace not found: %s", tree.CurrentNodeID)
|
|
}
|
|
|
|
// Save persists UI state only; workspace existence is never persisted here.
|
|
func (m *Manager) Save() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.writeUIStateLocked()
|
|
}
|
|
|
|
func applyTemplate(workspaceDir string, template templateDefinition) error {
|
|
for rel, content := range template.Files {
|
|
if strings.Contains(rel, "\x00") || strings.Contains(rel, "\\") || strings.HasPrefix(rel, "/") {
|
|
return fmt.Errorf("invalid-template-path: %s", rel)
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
for _, part := range parts {
|
|
if part == "" || part == "." || part == ".." {
|
|
return fmt.Errorf("invalid-template-path: %s", rel)
|
|
}
|
|
}
|
|
full := filepath.Join(workspaceDir, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, folder := range template.Folders {
|
|
if folder == "" {
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(workspaceDir, folder), 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateWorkspaceName(name string) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return fmt.Errorf("invalid-workspace-name: empty")
|
|
}
|
|
if strings.Contains(name, "\x00") {
|
|
return fmt.Errorf("invalid-workspace-name: null-byte")
|
|
}
|
|
if strings.ContainsAny(name, `/\`) {
|
|
return fmt.Errorf("invalid-workspace-name: path separators are not allowed")
|
|
}
|
|
if looksAbsoluteName(name) {
|
|
return fmt.Errorf("invalid-workspace-name: absolute path rejected")
|
|
}
|
|
if name == "." || name == ".." || strings.Contains(name, "..") {
|
|
return fmt.Errorf("invalid-workspace-name: path traversal")
|
|
}
|
|
for _, r := range name {
|
|
if unicode.IsControl(r) {
|
|
return fmt.Errorf("invalid-workspace-name: control character")
|
|
}
|
|
}
|
|
if isReservedWorkspaceName(name) {
|
|
return fmt.Errorf("reserved-workspace-name: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func looksAbsoluteName(name string) bool {
|
|
if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") {
|
|
return true
|
|
}
|
|
return len(name) >= 2 && name[1] == ':' && unicode.IsLetter(rune(name[0]))
|
|
}
|
|
|
|
func isReservedWorkspaceName(name string) bool {
|
|
reserved := []string{".verstak", ".git"}
|
|
for _, item := range reserved {
|
|
if strings.EqualFold(name, item) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureExistingWorkspaceDir(full, name string) error {
|
|
info, err := os.Lstat(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("not-found: %s", name)
|
|
}
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink-not-allowed: %s", name)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("not-directory: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) metadataPath(name string) string {
|
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(name))
|
|
return filepath.Join(m.vaultDir, ".verstak", "workspaces", encoded+".json")
|
|
}
|
|
|
|
func (m *Manager) writeMetadata(name string, meta Metadata) error {
|
|
if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak", "workspaces"), 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := m.metadataPath(name)
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) uiStatePath() string {
|
|
return filepath.Join(m.vaultDir, ".verstak", "workspace-ui.json")
|
|
}
|
|
|
|
func (m *Manager) readSelectedWorkspaceLocked() string {
|
|
data, err := os.ReadFile(m.uiStatePath())
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var state struct {
|
|
SelectedWorkspace string `json:"selectedWorkspace"`
|
|
}
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return ""
|
|
}
|
|
return state.SelectedWorkspace
|
|
}
|
|
|
|
func (m *Manager) writeUIStateLocked() error {
|
|
if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak"), 0o755); err != nil {
|
|
return err
|
|
}
|
|
state := struct {
|
|
SelectedWorkspace string `json:"selectedWorkspace,omitempty"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}{
|
|
SelectedWorkspace: m.currentWorkspaceName,
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
}
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := m.uiStatePath()
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func genericMetadata(name string) Metadata {
|
|
return Metadata{
|
|
WorkspaceName: name,
|
|
Features: map[string]bool{"files": true},
|
|
Folders: defaultFolders(),
|
|
}
|
|
}
|
|
|
|
func defaultFolders() map[string]string {
|
|
return map[string]string{
|
|
"notes": "Notes",
|
|
"files": "Files",
|
|
}
|
|
}
|
|
|
|
func cloneBoolMap(src map[string]bool) map[string]bool {
|
|
dst := make(map[string]bool, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func cloneStringMap(src map[string]string) map[string]string {
|
|
dst := make(map[string]string, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func hasAnyTrueFeature(features map[string]bool) bool {
|
|
for _, enabled := range features {
|
|
if enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func workspaceExists(workspaces []Workspace, name string) bool {
|
|
for _, ws := range workspaces {
|
|
if ws.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func moveIfExists(from, to string) error {
|
|
if _, err := os.Stat(from); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(from, to)
|
|
}
|
|
|
|
// ClearTemplateRegistryForTest simulates templates disappearing after a workspace
|
|
// has already stored its creation snapshot.
|
|
func ClearTemplateRegistryForTest(t interface{ Cleanup(func()) }) {
|
|
original := builtInTemplates
|
|
builtInTemplates = map[string]templateDefinition{}
|
|
t.Cleanup(func() {
|
|
builtInTemplates = original
|
|
})
|
|
}
|