Compare commits

...

6 Commits

Author SHA1 Message Date
MangoPig b232d6ef36 Fix: harden local dev startup flow 2026-07-06 13:06:32 +01:00
MangoPig 58bc285748 Refactor: migrate POSIX scaffolding to CBOR and JSONC 2026-07-06 13:06:32 +01:00
MangoPig 2c8338d1cd Merge branch 'Refactor/Code-Modularity' 2026-06-29 04:29:58 +01:00
MangoPig 95fb8629d1 Refactor: improve backend modularity 2026-06-29 04:29:05 +01:00
MangoPig fc5ef23af8 Refactor: improve code modularity 2026-06-29 03:06:18 +01:00
MangoPig c47eae1381 Merge branch 'Refactor/Sidebar-Usability' 2026-06-28 19:19:10 +01:00
124 changed files with 9280 additions and 8275 deletions
+3
View File
@@ -6,6 +6,9 @@ WORKDIR /app
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
RUN mkdir -p /tmp/home /tmp/go/pkg/mod /tmp/go-build \
&& chmod 0777 /tmp/home /tmp/go /tmp/go/pkg /tmp/go/pkg/mod /tmp/go-build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
+3
View File
@@ -3,11 +3,13 @@ module moku-backend
go 1.25.7 go 1.25.7
require ( require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.20.1 github.com/redis/go-redis/v9 v9.20.1
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
) )
require ( require (
@@ -17,6 +19,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
+8
View File
@@ -9,8 +9,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -44,6 +48,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -0,0 +1,81 @@
// Path: Backend/internal/bootstrap/bootstrap_helpers.go
package bootstrap
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
func NewService(db *database.DB, posixRoot string) *Service {
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
}
func upsertNamedRecord(ctx context.Context, tx pgx.Tx, query string, args ...any) (namedRecord, error) {
var record namedRecord
if err := tx.QueryRow(ctx, query, args...).Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return namedRecord{}, err
}
return record, nil
}
func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, kind, createdByUserID string, departmentID, teamID, projectID *string) error {
_, err := tx.Exec(ctx, `
INSERT INTO workspaces (organization_id, name, slug, kind, created_by_user_id, department_id, team_id, project_id)
VALUES ($1::uuid, $2, $3, $4::workspace_kind, $5::uuid, $6::uuid, $7::uuid, $8::uuid)
ON CONFLICT (organization_id, slug) DO UPDATE
SET
name = EXCLUDED.name,
kind = EXCLUDED.kind,
created_by_user_id = EXCLUDED.created_by_user_id,
department_id = EXCLUDED.department_id,
team_id = EXCLUDED.team_id,
project_id = EXCLUDED.project_id,
updated_at = NOW();
`, organizationID, name, slug, kind, createdByUserID, departmentID, teamID, projectID)
return err
}
func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string {
trimmedInstallationName := strings.TrimSpace(installationName)
trimmedHost := strings.TrimSpace(host)
trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName)
if trimmedInstallationName != "" {
return trimmedInstallationName
}
if strings.EqualFold(mode, defaultInstallationMode) {
if trimmedAdminDisplayName != "" {
return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix)
}
return defaultPersonalDisplayName
}
if trimmedHost != "" {
return trimmedHost
}
return defaultOrganizationName
}
func personalHomeTitle(displayName string) string {
trimmedDisplayName := strings.TrimSpace(displayName)
if trimmedDisplayName == "" {
return "Home"
}
if strings.HasSuffix(strings.ToLower(trimmedDisplayName), "s") {
return fmt.Sprintf("%s' Home", trimmedDisplayName)
}
return fmt.Sprintf("%s's Home", trimmedDisplayName)
}
@@ -0,0 +1,309 @@
// Path: Backend/internal/bootstrap/bootstrap_types.go
package bootstrap
import (
"errors"
"moku-backend/internal/database"
)
const (
primaryOrganizationSlug = "primary-organization"
primaryDepartmentSlug = "primary-department"
primaryTeamSlug = "primary-team"
primaryProjectSlug = "primary-project"
organizationWorkspaceSlug = "organization-home"
departmentWorkspaceSlug = "department-home"
teamWorkspaceSlug = "team-home"
projectWorkspaceSlug = "project-home"
defaultInstallationHost = "localhost"
defaultInstallationMode = "personal"
defaultInstallationAccess = "local"
defaultInstallationProtocol = "http"
defaultOrganizationName = "Moku"
defaultPersonalServerSuffix = "Personal"
defaultPersonalDisplayName = "Personal"
bootstrapWorkspaceKindOrg = "organization"
bootstrapWorkspaceKindDept = "department"
bootstrapWorkspaceKindTeam = "team"
bootstrapWorkspaceKindProject = "project"
projectFolderOrderRootKey = "__root__"
projectFolderOrderHierarchy = "hierarchy"
projectFolderOrderTree = "tree"
)
var (
ErrInstallationNotConfigured = errors.New("bootstrap installation step has not been completed")
ErrAdminNotConfigured = errors.New("bootstrap admin step has not been completed")
ErrBootstrapStructureMissing = errors.New("bootstrap structure is incomplete")
ErrProjectNotFound = errors.New("project not found")
ErrProjectFolderNotFound = errors.New("project folder not found")
ErrProjectItemNotFound = errors.New("project item not found")
ErrInvalidProjectFolderMove = errors.New("invalid project folder move")
ErrInvalidProjectItemMove = errors.New("invalid project item move")
)
type Service struct {
db *database.DB
posixRoot string
}
type SaveInstanceInput struct {
Protocol string
Access string
Host string
}
type SaveModeInput struct {
Mode string
Name string
}
type SaveAdminInput struct {
DisplayName string
Email string
Password string
}
type SaveStructureInput struct {
OrganizationName string
DepartmentName string
TeamName string
ProjectName string
}
type InstallationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Access string `json:"access"`
Protocol string `json:"protocol"`
Host string `json:"host"`
IsBootstrapped bool `json:"isBootstrapped"`
MaterializationStatus string `json:"materializationStatus"`
MaterializationError *string `json:"materializationError,omitempty"`
}
type AdminRecord struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IsInstanceAdmin bool `json:"isInstanceAdmin"`
HomeTitle string `json:"homeTitle"`
}
type OrganizationRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type DepartmentRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type TeamRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type WorkspaceRecord struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
DepartmentID *string `json:"departmentId,omitempty"`
TeamID *string `json:"teamId,omitempty"`
ProjectID *string `json:"projectId,omitempty"`
}
type StructureRecord struct {
Installation InstallationRecord `json:"installation"`
Organization namedRecord `json:"organization"`
Department namedRecord `json:"department"`
Team namedRecord `json:"team"`
Project namedRecord `json:"project"`
Admin AdminSummary `json:"admin"`
}
type AdminSummary struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
}
type BootstrapStructureState struct {
Organization *OrganizationRecord `json:"organization,omitempty"`
Department *DepartmentRecord `json:"department,omitempty"`
Team *TeamRecord `json:"team,omitempty"`
Project *ProjectRecord `json:"project,omitempty"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type BootstrapState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Structure BootstrapStructureState `json:"structure"`
}
type AppShellState struct {
Installation *InstallationRecord `json:"installation,omitempty"`
Admin *AdminRecord `json:"admin,omitempty"`
Organizations []OrganizationRecord `json:"organizations"`
Departments []DepartmentRecord `json:"departments"`
Teams []TeamRecord `json:"teams"`
Projects []ProjectRecord `json:"projects"`
Workspaces []WorkspaceRecord `json:"workspaces"`
}
type namedRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type ProjectHierarchyFolderRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Children []ProjectHierarchyFolderRecord `json:"children"`
}
type ProjectTreeNodeRecord struct {
ID string `json:"id"`
Path string `json:"path"`
Label string `json:"label"`
Kind string `json:"kind"`
ItemType string `json:"itemType,omitempty"`
Children []ProjectTreeNodeRecord `json:"children,omitempty"`
}
type CreateProjectFolderInput struct {
ProjectID string
ParentFolderPath string
Name string
}
type DeleteProjectFolderInput struct {
ProjectID string
FolderPath string
}
type RenameProjectFolderInput struct {
ProjectID string
FolderPath string
Name string
}
type MoveProjectFolderInput struct {
ProjectID string
FolderPath string
FolderStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectItemInput struct {
ProjectID string
ParentFolderPath string
Name string
ItemType string
}
type DeleteProjectItemInput struct {
ProjectID string
ItemPath string
}
type MoveProjectItemInput struct {
ProjectID string
ItemPath string
ItemStableID string
ParentFolderPath string
ParentStableID string
TargetIndex int
}
type CreateProjectFolderResult struct {
ProjectID string `json:"projectId"`
CreatedFolder ProjectHierarchyFolderRecord `json:"createdFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type DeleteProjectFolderResult struct {
ProjectID string `json:"projectId"`
DeletedFolderStableID string `json:"deletedFolderId"`
DeletedFolderPath string `json:"deletedFolderPath"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type RenameProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
RenamedFolder ProjectHierarchyFolderRecord `json:"renamedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type MoveProjectFolderResult struct {
ProjectID string `json:"projectId"`
PreviousFolderStableID string `json:"previousFolderId"`
PreviousFolderPath string `json:"previousFolderPath"`
MovedFolder ProjectHierarchyFolderRecord `json:"movedFolder"`
Folders []ProjectHierarchyFolderRecord `json:"folders"`
}
type CreateProjectItemResult struct {
ProjectID string `json:"projectId"`
CreatedItem ProjectTreeNodeRecord `json:"createdItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type DeleteProjectItemResult struct {
ProjectID string `json:"projectId"`
DeletedItemStableID string `json:"deletedItemId"`
DeletedItemPath string `json:"deletedItemPath"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type MoveProjectItemResult struct {
ProjectID string `json:"projectId"`
PreviousItemStableID string `json:"previousItemId"`
PreviousItemPath string `json:"previousItemPath"`
MovedItem ProjectTreeNodeRecord `json:"movedItem"`
Nodes []ProjectTreeNodeRecord `json:"nodes"`
}
type projectHierarchyFolderRow struct {
ID string
Path string
ParentPath string
Label string
}
type projectTreeNodeRow struct {
ID string
Path string
ParentPath string
Label string
Kind string
ItemType string
}
@@ -0,0 +1,48 @@
// Path: Backend/internal/bootstrap/project_disk.go
package bootstrap
import (
"path/filepath"
)
func (service *Service) createProjectHierarchyFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectHierarchyRootPath)
}
func (service *Service) createProjectTreeFolderOnDisk(projectSlug, parentFolderID, name string) (string, string, error) {
return service.createProjectFolderOnDisk(projectSlug, parentFolderID, name, projectTreeRootPath)
}
func (service *Service) deleteProjectHierarchyFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectHierarchyRootPath)
}
func (service *Service) deleteProjectTreeFolderOnDisk(projectSlug, folderID string) (string, error) {
return service.deleteProjectFolderOnDisk(projectSlug, folderID, projectTreeRootPath)
}
func (service *Service) renameProjectHierarchyFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectHierarchyRootPath)
}
func (service *Service) renameProjectTreeFolderOnDisk(projectSlug, folderID, name string) (string, string, error) {
return service.renameProjectFolderOnDisk(projectSlug, folderID, name, projectTreeRootPath)
}
func (service *Service) moveProjectHierarchyFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectHierarchyRootPath)
}
func (service *Service) moveProjectTreeFolderOnDisk(projectSlug, folderID, parentFolderID string) (string, string, error) {
return service.moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID, projectTreeRootPath)
}
func projectHierarchyRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "children"))
}
func projectTreeRootPath(projectSlug string) string {
return filepath.ToSlash(filepath.Join("projects", slugDir("project", projectSlug), "tree"))
}
@@ -0,0 +1,257 @@
// Path: Backend/internal/bootstrap/project_disk_bootstrap.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func (service *Service) ensureBootstrapPOSIXSkeleton(
installation InstallationRecord,
admin AdminSummary,
organization namedRecord,
department namedRecord,
team namedRecord,
project namedRecord,
) error {
rootPath := strings.TrimSpace(service.posixRoot)
if rootPath == "" {
return nil
}
if err := os.MkdirAll(rootPath, 0o755); err != nil {
return fmt.Errorf("create POSIX root: %w", err)
}
if err := writeCBORFile(filepath.Join(rootPath, posixSettingsFileName), map[string]any{
"installation": map[string]any{
"id": installation.ID,
"name": installation.Name,
"mode": installation.Mode,
"access": installation.Access,
"protocol": installation.Protocol,
"host": installation.Host,
"isBootstrapped": installation.IsBootstrapped,
},
"organization": map[string]any{
"id": organization.ID,
"name": organization.Name,
"slug": organization.Slug,
},
}); err != nil {
return fmt.Errorf("write tenant %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(rootPath, posixLayoutFileName), map[string]any{
"version": 1,
"type": "tenant-layout",
"home": map[string]any{
"defaultProjectSlug": project.Slug,
},
}); err != nil {
return fmt.Errorf("write tenant %s: %w", posixLayoutFileName, err)
}
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil {
return fmt.Errorf("create catalog packs root: %w", err)
}
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil {
return fmt.Errorf("create catalog standalone root: %w", err)
}
corePackPath := filepath.Join(rootPath, "catalog", "packs", "pack-core")
corePackEntriesPath := filepath.Join(corePackPath, "entries")
coreAppEntryPath := filepath.Join(corePackEntriesPath, "app-shell")
standaloneAppPath := filepath.Join(rootPath, "catalog", "standalone", "app-shell")
for _, dirPath := range []string{corePackPath, corePackEntriesPath, coreAppEntryPath, standaloneAppPath} {
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("create catalog directory %s: %w", dirPath, err)
}
}
if err := writeJSONCFile(filepath.Join(corePackPath, posixManifestFileName), map[string]any{
"id": "pack-core",
"slug": "core",
"type": "pack",
"name": "Core Pack",
"version": "0.1.0",
"description": "First-party shared package of starter Moku surfaces.",
"entries": []map[string]any{{
"slug": "app-shell",
"path": "entries/app-shell",
}},
"capabilities": []string{"first-party", "bootstrap", "catalog-pack"},
}); err != nil {
return fmt.Errorf("write catalog pack %s: %w", posixManifestFileName, err)
}
if err := writeJSONCFile(filepath.Join(coreAppEntryPath, posixManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"name": "App Shell",
"version": "0.1.0",
"description": "Primary shell surface for navigating the installation.",
"source": "pack-core",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
"capabilities": []string{"shell", "navigation", "workspace"},
}); err != nil {
return fmt.Errorf("write catalog entry %s: %w", posixManifestFileName, err)
}
if err := writeJSONCFile(filepath.Join(standaloneAppPath, posixManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"name": "App Shell",
"version": "0.1.0",
"description": "Standalone registration for the primary shell surface.",
"source": "standalone",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
"capabilities": []string{"shell", "navigation", "standalone"},
}); err != nil {
return fmt.Errorf("write standalone entry %s: %w", posixManifestFileName, err)
}
departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug))
teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug))
projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug))
usersPath := filepath.Join(rootPath, "users")
personalName := strings.TrimSpace(admin.DisplayName)
if personalName == "" {
personalName = defaultPersonalDisplayName
}
personalSlug := normalizePOSIXSlug(personalName)
personalHomePath := filepath.Join(usersPath, "personals", slugDir("personal", personalSlug))
for _, dirPath := range []string{
departmentPath,
teamPath,
projectPath,
filepath.Join(projectPath, "children"),
filepath.Join(projectPath, "tree"),
filepath.Join(usersPath, "personals"),
personalHomePath,
filepath.Join(personalHomePath, "tree"),
} {
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("create POSIX directory %s: %w", dirPath, err)
}
}
if err := writeCBORFile(filepath.Join(departmentPath, posixSettingsFileName), map[string]any{
"id": department.ID,
"name": department.Name,
"slug": department.Slug,
"type": "department",
}); err != nil {
return fmt.Errorf("write department %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(departmentPath, posixUsersFileName), map[string]any{
"owners": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write department %s: %w", posixUsersFileName, err)
}
if err := writeCBORFile(filepath.Join(teamPath, posixSettingsFileName), map[string]any{
"id": team.ID,
"name": team.Name,
"slug": team.Slug,
"type": "team",
}); err != nil {
return fmt.Errorf("write team %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(teamPath, posixUsersFileName), map[string]any{
"owners": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write team %s: %w", posixUsersFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixSettingsFileName), map[string]any{
"id": project.ID,
"name": project.Name,
"slug": project.Slug,
"type": "project",
}); err != nil {
return fmt.Errorf("write project %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixHomeFileName), map[string]any{
"type": "project-home",
"title": project.Name,
}); err != nil {
return fmt.Errorf("write project %s: %w", posixHomeFileName, err)
}
if err := writeCBORFile(filepath.Join(projectPath, posixACLFileName), map[string]any{
"version": 1,
"inherits": true,
"rules": []any{},
}); err != nil {
return fmt.Errorf("write project %s: %w", posixACLFileName, err)
}
if err := writeCBORFile(filepath.Join(usersPath, posixSettingsFileName), map[string]any{
"primaryAdminId": admin.ID,
}); err != nil {
return fmt.Errorf("write users %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(usersPath, posixDataFileName), map[string]any{
"admins": []map[string]string{{
"id": admin.ID,
"email": admin.Email,
"displayName": admin.DisplayName,
}},
}); err != nil {
return fmt.Errorf("write users %s: %w", posixDataFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixSettingsFileName), map[string]any{
"type": "personal",
"name": personalName,
"slug": personalSlug,
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixSettingsFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixLayoutFileName), map[string]any{
"version": 1,
"type": "personal-layout",
"home": map[string]any{
"defaultProjectSlug": project.Slug,
},
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixLayoutFileName, err)
}
if err := writeCBORFile(filepath.Join(personalHomePath, posixHomeFileName), map[string]any{
"type": "personal-home",
"title": personalHomeTitle(personalName),
}); err != nil {
return fmt.Errorf("write personal %s: %w", posixHomeFileName, err)
}
return nil
}
@@ -0,0 +1,252 @@
// Path: Backend/internal/bootstrap/project_disk_folders.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectFolderOnDisk(projectSlug, parentFolderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", "", fmt.Errorf("folder name is required")
}
containerProjectionPath := rootPathBuilder(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if strings.TrimSpace(parentFolderID) != "" {
containerProjectionPath = filepath.ToSlash(filepath.Join(strings.TrimSpace(parentFolderID), "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", "", fmt.Errorf("create parent project folder path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
folderName := slugDir("folder", baseSlug)
folderDir := filepath.Join(parentDir, folderName)
folderSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(folderDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderName = slugDir("folder", folderSlug)
folderDir = filepath.Join(parentDir, folderName)
}
if err := os.MkdirAll(filepath.Join(folderDir, "children"), 0o755); err != nil {
return "", "", fmt.Errorf("create project hierarchy folder: %w", err)
}
folderID := uuid.NewString()
if err := writeCBORFile(filepath.Join(folderDir, posixFolderFileName), map[string]any{"id": folderID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write project %s: %w", posixFolderFileName, err)
}
if err := writeCBORFile(filepath.Join(folderDir, posixACLFileName), map[string]any{"version": 1, "inherits": true, "rules": []any{}}); err != nil {
return "", "", fmt.Errorf("write project %s: %w", posixACLFileName, err)
}
return filepath.ToSlash(filepath.Join(containerProjectionPath, folderName)), folderSlug, nil
}
func (service *Service) deleteProjectFolderOnDisk(projectSlug, folderID string, rootPathBuilder func(projectSlug string) string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return "", ErrProjectFolderNotFound
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
if err := os.RemoveAll(folderDir); err != nil {
return "", fmt.Errorf("delete project folder: %w", err)
}
return folderProjectionPath, nil
}
func (service *Service) renameProjectFolderOnDisk(projectSlug, folderID, name string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return "", "", ErrProjectFolderNotFound
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", "", fmt.Errorf("folder name is required")
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedFolderID)), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
parentDir := filepath.Dir(folderDir)
baseSlug := normalizePOSIXSlug(trimmedName)
folderName := slugDir("folder", baseSlug)
folderSlug := baseSlug
destinationDir := filepath.Join(parentDir, folderName)
for attempt := 2; ; attempt += 1 {
if destinationDir == folderDir {
break
}
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate renamed project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderName = slugDir("folder", folderSlug)
destinationDir = filepath.Join(parentDir, folderName)
}
renamedProjectionPath := filepath.ToSlash(filepath.Join(filepath.Dir(folderProjectionPath), folderName))
if destinationDir != folderDir {
if err := os.Rename(folderDir, destinationDir); err != nil {
return "", "", fmt.Errorf("rename project folder: %w", err)
}
}
folderPayload := readStructuredFileMap(filepath.Join(destinationDir, posixFolderFileName))
folderMetadataID, _ := folderPayload["id"].(string)
if strings.TrimSpace(folderMetadataID) == "" {
folderMetadataID = uuid.NewString()
}
if err := writeCBORFile(filepath.Join(destinationDir, posixFolderFileName), map[string]any{"id": folderMetadataID, "name": trimmedName, "slug": folderSlug, "type": "folder"}); err != nil {
return "", "", fmt.Errorf("write renamed project %s: %w", posixFolderFileName, err)
}
return folderProjectionPath, renamedProjectionPath, nil
}
func (service *Service) moveProjectFolderOnDisk(projectSlug, folderID, parentFolderID string, rootPathBuilder func(projectSlug string) string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := rootPathBuilder(projectSlug)
folderProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(folderID))), "/")
if folderProjectionPath == "." || folderProjectionPath == rootProjectionPath || !strings.HasPrefix(folderProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
folderDir := filepath.Join(posixRoot, filepath.FromSlash(folderProjectionPath))
info, err := os.Stat(folderDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectFolderNotFound
}
trimmedParentFolderID := strings.TrimSpace(parentFolderID)
parentChildrenProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(rootProjectionPath))
if trimmedParentFolderID != "" {
parentProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderID)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
if parentProjectionPath == folderProjectionPath || strings.HasPrefix(parentProjectionPath, folderProjectionPath+"/children/") {
return "", "", ErrInvalidProjectFolderMove
}
parentChildrenProjectionPath = filepath.ToSlash(filepath.Join(parentProjectionPath, "children"))
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentChildrenProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project folder parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(folderDir)
if samePath(currentParentDir, parentDir) {
return folderProjectionPath, folderProjectionPath, nil
}
folderPayload := readStructuredFileMap(filepath.Join(folderDir, posixFolderFileName))
folderMetadataID, _ := folderPayload["id"].(string)
if strings.TrimSpace(folderMetadataID) == "" {
folderMetadataID = uuid.NewString()
}
folderName, _ := folderPayload["name"].(string)
if strings.TrimSpace(folderName) == "" {
folderName = fallbackFolderLabel(folderProjectionPath)
}
currentBase := filepath.Base(folderDir)
baseSlug := strings.TrimPrefix(currentBase, "folder-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(folderName)
}
folderSlug := baseSlug
folderDirName := slugDir("folder", folderSlug)
destinationDir := filepath.Join(parentDir, folderDirName)
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project folder: %w", err)
}
folderSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
folderDirName = slugDir("folder", folderSlug)
destinationDir = filepath.Join(parentDir, folderDirName)
}
if err := os.Rename(folderDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project folder: %w", err)
}
folderPayload["id"] = folderMetadataID
folderPayload["name"] = folderName
folderPayload["slug"] = folderSlug
folderPayload["type"] = "folder"
if err := writeCBORFile(filepath.Join(destinationDir, posixFolderFileName), folderPayload); err != nil {
return "", "", fmt.Errorf("write moved project %s: %w", posixFolderFileName, err)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentChildrenProjectionPath, folderDirName))
return folderProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,233 @@
// Path: Backend/internal/bootstrap/project_disk_helpers.go
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
)
const (
posixSettingsFileName = "settings.cbor"
posixLayoutFileName = "layout.cbor"
posixHomeFileName = "home.cbor"
posixUsersFileName = "users.cbor"
posixACLFileName = "acl.cbor"
posixFolderFileName = "folder.cbor"
posixItemFileName = "item.cbor"
posixDataFileName = "data.cbor"
posixSchemaFileName = "schema.json"
posixManifestFileName = "manifest.jsonc"
)
func samePath(left, right string) bool {
cleanLeft := filepath.Clean(left)
cleanRight := filepath.Clean(right)
if cleanLeft == cleanRight {
return true
}
leftInfo, leftErr := os.Stat(cleanLeft)
rightInfo, rightErr := os.Stat(cleanRight)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
return false
}
func normalizePOSIXSlug(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return "untitled"
}
var builder strings.Builder
lastDash := false
for _, r := range trimmed {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
builder.WriteRune(r)
lastDash = false
case r == '-' || r == '_' || unicode.IsSpace(r):
if !lastDash && builder.Len() > 0 {
builder.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
return "untitled"
}
return slug
}
func fallbackFolderLabel(path string) string {
base := filepath.Base(filepath.FromSlash(path))
trimmed := strings.TrimPrefix(base, "folder-")
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
for index, part := range parts {
if part != "" {
parts[index] = strings.ToUpper(part[:1]) + part[1:]
}
}
label := strings.Join(parts, " ")
if label == "" {
return base
}
return label
}
func fallbackItemLabel(path string) string {
base := filepath.Base(filepath.FromSlash(path))
trimmed := strings.TrimPrefix(base, "item-")
parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '-' || r == '_' })
for index, part := range parts {
if part != "" {
parts[index] = strings.ToUpper(part[:1]) + part[1:]
}
}
label := strings.Join(parts, " ")
if label == "" {
return base
}
return label
}
func normalizeProjectTreeItemType(itemType string) string {
switch strings.TrimSpace(strings.ToLower(itemType)) {
case "", "board", "core.board", "core.board.kanban", "kanban":
return "core.board.kanban"
case "core.doc", "doc", "document":
return "core.doc"
case "core.board.list", "list", "list-board":
return "core.board.list"
default:
return strings.TrimSpace(itemType)
}
}
func defaultProjectTreeItemSchema(itemType string) map[string]any {
return map[string]any{"type": "object", "itemType": normalizeProjectTreeItemType(itemType)}
}
func defaultProjectTreeItemData(itemType, name string) map[string]any {
return map[string]any{"title": strings.TrimSpace(name), "itemType": normalizeProjectTreeItemType(itemType)}
}
func slugDir(prefix, slug string) string {
trimmedSlug := strings.TrimSpace(slug)
if trimmedSlug == "" {
return prefix
}
return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
}
func writeJSONFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
func writeJSONCFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
content := append([]byte("// This file is JSONC. Comments and trailing commas are allowed.\n"), data...)
content = append(content, '\n')
return os.WriteFile(path, content, 0o644)
}
func writeCBORFile(path string, payload any) error {
parentDir := filepath.Dir(path)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return err
}
data, err := cbor.Marshal(payload)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func readStructuredFileMap(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return map[string]any{}
}
var payload any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
err = cbor.Unmarshal(data, &payload)
case ".json":
err = json.Unmarshal(data, &payload)
case ".jsonc":
payload, err = decodeJSONCToAny(data)
default:
return map[string]any{}
}
if err != nil || payload == nil {
return map[string]any{}
}
normalized, ok := normalizeStructuredValue(payload).(map[string]any)
if !ok || normalized == nil {
return map[string]any{}
}
return normalized
}
func decodeJSONCToAny(data []byte) (any, error) {
ast, err := hujson.Parse(data)
if err != nil {
return nil, err
}
ast.Standardize()
standardized := ast.Pack()
var payload any
if err := json.Unmarshal(standardized, &payload); err != nil {
return nil, err
}
return payload, nil
}
func normalizeStructuredValue(value any) any {
switch typed := value.(type) {
case map[string]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[key] = normalizeStructuredValue(child)
}
return normalized
case map[any]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[fmt.Sprint(key)] = normalizeStructuredValue(child)
}
return normalized
case []any:
normalized := make([]any, len(typed))
for index, child := range typed {
normalized[index] = normalizeStructuredValue(child)
}
return normalized
default:
return value
}
}
@@ -0,0 +1,189 @@
// Path: Backend/internal/bootstrap/project_disk_items.go
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
func (service *Service) createProjectTreeItemOnDisk(projectSlug, parentFolderPath, name, itemType string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", fmt.Errorf("item name is required")
}
canonicalItemType := normalizeProjectTreeItemType(itemType)
containerProjectionPath := projectTreeRootPath(projectSlug)
parentDir := filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
if trimmedParentFolderPath := strings.TrimSpace(parentFolderPath); trimmedParentFolderPath != "" {
containerProjectionPath = trimmedParentFolderPath
parentDir = filepath.Join(posixRoot, filepath.FromSlash(containerProjectionPath))
info, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectFolderNotFound
}
return "", fmt.Errorf("stat parent project folder: %w", err)
}
if !info.IsDir() {
return "", ErrProjectFolderNotFound
}
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return "", fmt.Errorf("create parent project item path: %w", err)
}
baseSlug := normalizePOSIXSlug(trimmedName)
itemDirName := slugDir("item", baseSlug)
itemDir := filepath.Join(parentDir, itemDirName)
itemSlug := baseSlug
for attempt := 2; ; attempt += 1 {
if _, err := os.Stat(itemDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", fmt.Errorf("stat candidate project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
itemDir = filepath.Join(parentDir, itemDirName)
}
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return "", fmt.Errorf("create project item: %w", err)
}
itemID := uuid.NewString()
if err := writeCBORFile(filepath.Join(itemDir, posixItemFileName), map[string]any{"id": itemID, "name": trimmedName, "slug": itemSlug, "type": canonicalItemType}); err != nil {
return "", fmt.Errorf("write project %s: %w", posixItemFileName, err)
}
if err := writeJSONFile(filepath.Join(itemDir, posixSchemaFileName), defaultProjectTreeItemSchema(canonicalItemType)); err != nil {
return "", fmt.Errorf("write project %s: %w", posixSchemaFileName, err)
}
if err := writeCBORFile(filepath.Join(itemDir, posixDataFileName), defaultProjectTreeItemData(canonicalItemType, trimmedName)); err != nil {
return "", fmt.Errorf("write project %s: %w", posixDataFileName, err)
}
return filepath.ToSlash(filepath.Join(containerProjectionPath, itemDirName)), nil
}
func (service *Service) deleteProjectTreeItemOnDisk(projectSlug, itemPath string) (string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", ErrProjectItemNotFound
}
return "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", ErrProjectItemNotFound
}
if err := os.RemoveAll(itemDir); err != nil {
return "", fmt.Errorf("delete project item: %w", err)
}
return itemProjectionPath, nil
}
func (service *Service) moveProjectTreeItemOnDisk(projectSlug, itemPath, parentFolderPath string) (string, string, error) {
posixRoot := strings.TrimSpace(service.posixRoot)
if posixRoot == "" {
return "", "", fmt.Errorf("POSIX root is not configured")
}
rootProjectionPath := projectTreeRootPath(projectSlug)
itemProjectionPath := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+strings.TrimSpace(itemPath))), "/")
if itemProjectionPath == "." || itemProjectionPath == rootProjectionPath || !strings.HasPrefix(itemProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectItemNotFound
}
itemDir := filepath.Join(posixRoot, filepath.FromSlash(itemProjectionPath))
info, err := os.Stat(itemDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectItemNotFound
}
return "", "", fmt.Errorf("stat project item: %w", err)
}
if !info.IsDir() {
return "", "", ErrProjectItemNotFound
}
trimmedParentFolderPath := strings.TrimSpace(parentFolderPath)
parentProjectionPath := rootProjectionPath
parentDir := filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
if trimmedParentFolderPath != "" {
parentProjectionPath = strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+trimmedParentFolderPath)), "/")
if parentProjectionPath == "." || parentProjectionPath == rootProjectionPath || !strings.HasPrefix(parentProjectionPath, rootProjectionPath+"/") {
return "", "", ErrProjectFolderNotFound
}
parentDir = filepath.Join(posixRoot, filepath.FromSlash(parentProjectionPath))
}
parentInfo, err := os.Stat(parentDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", ErrProjectFolderNotFound
}
return "", "", fmt.Errorf("stat project item parent: %w", err)
}
if !parentInfo.IsDir() {
return "", "", ErrProjectFolderNotFound
}
currentParentDir := filepath.Dir(itemDir)
currentBase := filepath.Base(itemDir)
itemPayload := readStructuredFileMap(filepath.Join(itemDir, posixItemFileName))
itemID, _ := itemPayload["id"].(string)
if strings.TrimSpace(itemID) == "" {
itemID = uuid.NewString()
}
itemName, _ := itemPayload["name"].(string)
if strings.TrimSpace(itemName) == "" {
itemName = fallbackItemLabel(itemProjectionPath)
}
itemType, _ := itemPayload["type"].(string)
canonicalItemType := normalizeProjectTreeItemType(itemType)
baseSlug := strings.TrimPrefix(currentBase, "item-")
if strings.TrimSpace(baseSlug) == "" {
baseSlug = normalizePOSIXSlug(itemName)
}
itemSlug := baseSlug
itemDirName := slugDir("item", itemSlug)
destinationDir := filepath.Join(parentDir, itemDirName)
for attempt := 2; ; attempt += 1 {
if samePath(destinationDir, itemDir) {
break
}
if _, err := os.Stat(destinationDir); os.IsNotExist(err) {
break
} else if err != nil {
return "", "", fmt.Errorf("stat candidate moved project item: %w", err)
}
itemSlug = fmt.Sprintf("%s-%d", baseSlug, attempt)
itemDirName = slugDir("item", itemSlug)
destinationDir = filepath.Join(parentDir, itemDirName)
}
movedProjectionPath := filepath.ToSlash(filepath.Join(parentProjectionPath, itemDirName))
if samePath(currentParentDir, parentDir) && currentBase == itemDirName {
return itemProjectionPath, itemProjectionPath, nil
}
if err := os.Rename(itemDir, destinationDir); err != nil {
return "", "", fmt.Errorf("move project item: %w", err)
}
itemPayload["id"] = itemID
itemPayload["name"] = itemName
itemPayload["slug"] = itemSlug
itemPayload["type"] = canonicalItemType
if err := writeCBORFile(filepath.Join(destinationDir, posixItemFileName), itemPayload); err != nil {
return "", "", fmt.Errorf("write moved project %s: %w", posixItemFileName, err)
}
return itemProjectionPath, movedProjectionPath, nil
}
@@ -0,0 +1,491 @@
// Path: Backend/internal/bootstrap/project_mutations.go
package bootstrap
import (
"context"
"fmt"
"strings"
)
func (service *Service) CreateProjectFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.createProjectHierarchyFolderOnDisk)
}
func (service *Service) CreateProjectTreeFolder(ctx context.Context, input CreateProjectFolderInput) (CreateProjectFolderResult, error) {
return service.createProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.createProjectTreeFolderOnDisk)
}
func (service *Service) DeleteProjectFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.deleteProjectHierarchyFolderOnDisk)
}
func (service *Service) DeleteProjectTreeFolder(ctx context.Context, input DeleteProjectFolderInput) (DeleteProjectFolderResult, error) {
return service.deleteProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.deleteProjectTreeFolderOnDisk)
}
func (service *Service) RenameProjectFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.renameProjectHierarchyFolderOnDisk)
}
func (service *Service) RenameProjectTreeFolder(ctx context.Context, input RenameProjectFolderInput) (RenameProjectFolderResult, error) {
return service.renameProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.renameProjectTreeFolderOnDisk)
}
func (service *Service) MoveProjectFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectHierarchyRootPath, service.moveProjectHierarchyFolderOnDisk)
}
func (service *Service) MoveProjectTreeFolder(ctx context.Context, input MoveProjectFolderInput) (MoveProjectFolderResult, error) {
return service.moveProjectHierarchyFolder(ctx, input, projectTreeRootPath, service.moveProjectTreeFolderOnDisk)
}
func (service *Service) CreateProjectTreeItem(ctx context.Context, input CreateProjectItemInput) (CreateProjectItemResult, error) {
return service.createProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) DeleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput) (DeleteProjectItemResult, error) {
return service.deleteProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) MoveProjectTreeItem(ctx context.Context, input MoveProjectItemInput) (MoveProjectItemResult, error) {
return service.moveProjectTreeItem(ctx, input, projectTreeRootPath)
}
func (service *Service) createProjectHierarchyFolder(
ctx context.Context,
input CreateProjectFolderInput,
rootPath func(projectSlug string) string,
createOnDisk func(projectSlug, parentFolderPath, name string) (string, string, error),
) (CreateProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return CreateProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, _, err := createOnDisk(project.Slug, strings.TrimSpace(input.ParentFolderPath), input.Name)
if err != nil {
return CreateProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok := findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdFolder.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectFolderResult{}, err
}
createdFolder, ok = findProjectHierarchyFolderByPath(folders, createdPath)
if !ok {
return CreateProjectFolderResult{}, fmt.Errorf("created project folder missing from ordered projection")
}
return CreateProjectFolderResult{ProjectID: project.ID, CreatedFolder: createdFolder, Folders: folders}, nil
}
func (service *Service) deleteProjectHierarchyFolder(
ctx context.Context,
input DeleteProjectFolderInput,
rootPath func(projectSlug string) string,
deleteOnDisk func(projectSlug, folderPath string) (string, error),
) (DeleteProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
deletedFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return DeleteProjectFolderResult{}, ErrProjectFolderNotFound
}
deletedFolderPath, err := deleteOnDisk(project.Slug, input.FolderPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
if _, found := findProjectHierarchyFolderByPath(folders, deletedFolderPath); found {
return DeleteProjectFolderResult{}, fmt.Errorf("deleted project folder still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrder(folderOrder, deletedFolder.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectFolderResult{}, err
}
return DeleteProjectFolderResult{ProjectID: project.ID, DeletedFolderStableID: deletedFolder.ID, DeletedFolderPath: deletedFolderPath, Folders: folders}, nil
}
func (service *Service) renameProjectHierarchyFolder(
ctx context.Context,
input RenameProjectFolderInput,
rootPath func(projectSlug string) string,
renameOnDisk func(projectSlug, folderPath, name string) (string, string, error),
) (RenameProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return RenameProjectFolderResult{}, err
}
previousFolderPath, renamedFolderPath, err := renameOnDisk(project.Slug, input.FolderPath, input.Name)
if err != nil {
return RenameProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return RenameProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return RenameProjectFolderResult{}, err
}
renamedFolder, found := findProjectHierarchyFolderByPath(folders, renamedFolderPath)
if !found {
return RenameProjectFolderResult{}, fmt.Errorf("renamed project folder missing from projection")
}
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return RenameProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
return RenameProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: renamedFolder.ID, PreviousFolderPath: previousFolderPath, RenamedFolder: renamedFolder, Folders: folders}, nil
}
func (service *Service) moveProjectHierarchyFolder(
ctx context.Context,
input MoveProjectFolderInput,
rootPath func(projectSlug string) string,
moveOnDisk func(projectSlug, folderPath, parentFolderPath string) (string, string, error),
) (MoveProjectFolderResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
currentFolder, found := findProjectHierarchyFolderByPath(currentFolders, strings.TrimSpace(input.FolderPath))
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
movedFolderStableID := currentFolder.ID
providedFolderStableID := strings.TrimSpace(input.FolderStableID)
if providedFolderStableID != "" && providedFolderStableID != movedFolderStableID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectHierarchyFolderByPath(currentFolders, trimmedParentFolderPath)
if !found {
return MoveProjectFolderResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
} else if providedParentStableID != "" {
return MoveProjectFolderResult{}, ErrInvalidProjectFolderMove
}
previousFolderPath, movedFolderPath, err := moveOnDisk(project.Slug, input.FolderPath, input.ParentFolderPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err := service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found := findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from projection")
}
if previousFolderPath != movedFolderPath {
if _, found := findProjectHierarchyFolderByPath(folders, previousFolderPath); found {
return MoveProjectFolderResult{}, fmt.Errorf("previous project folder path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedFolderOrderParent(folderOrder, currentFolders, parentOrderID)
removeFolderOrderReference(folderOrder, movedFolderStableID)
removeFolderOrderReference(folderOrder, movedFolder.ID)
insertFolderOrder(folderOrder, parentOrderID, movedFolder.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectFolderResult{}, err
}
folders, err = service.getProjectHierarchyFoldersByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectFolderResult{}, err
}
movedFolder, found = findProjectHierarchyFolderByPath(folders, movedFolderPath)
if !found {
return MoveProjectFolderResult{}, fmt.Errorf("moved project folder missing from ordered projection")
}
return MoveProjectFolderResult{ProjectID: project.ID, PreviousFolderStableID: movedFolder.ID, PreviousFolderPath: previousFolderPath, MovedFolder: movedFolder, Folders: folders}, nil
}
func (service *Service) createProjectTreeItem(ctx context.Context, input CreateProjectItemInput, rootPath func(projectSlug string) string) (CreateProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return CreateProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return CreateProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
}
createdPath, err := service.createProjectTreeItemOnDisk(project.Slug, trimmedParentFolderPath, input.Name, input.ItemType)
if err != nil {
return CreateProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok := findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
insertFolderOrder(folderOrder, parentOrderID, createdItem.ID, len(folderOrderChildren(folderOrder, parentOrderID)))
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return CreateProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return CreateProjectItemResult{}, err
}
createdItem, ok = findProjectTreeNodeByPath(nodes, createdPath)
if !ok || createdItem.Kind != "item" {
return CreateProjectItemResult{}, fmt.Errorf("created project item missing from ordered projection")
}
return CreateProjectItemResult{ProjectID: project.ID, CreatedItem: createdItem, Nodes: nodes}, nil
}
func (service *Service) deleteProjectTreeItem(ctx context.Context, input DeleteProjectItemInput, rootPath func(projectSlug string) string) (DeleteProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return DeleteProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
deletedItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || deletedItem.Kind != "item" {
return DeleteProjectItemResult{}, ErrProjectItemNotFound
}
deletedItemPath, err := service.deleteProjectTreeItemOnDisk(project.Slug, input.ItemPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
if _, found := findProjectTreeNodeByPath(nodes, deletedItemPath); found {
return DeleteProjectItemResult{}, fmt.Errorf("deleted project item still present in projection")
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
removeFolderOrderReference(folderOrder, deletedItem.ID)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return DeleteProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return DeleteProjectItemResult{}, err
}
return DeleteProjectItemResult{ProjectID: project.ID, DeletedItemStableID: deletedItem.ID, DeletedItemPath: deletedItemPath, Nodes: nodes}, nil
}
func (service *Service) moveProjectTreeItem(ctx context.Context, input MoveProjectItemInput, rootPath func(projectSlug string) string) (MoveProjectItemResult, error) {
project, err := service.loadProjectByID(ctx, strings.TrimSpace(input.ProjectID))
if err != nil {
return MoveProjectItemResult{}, err
}
currentNodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
currentItem, found := findProjectTreeNodeByPath(currentNodes, strings.TrimSpace(input.ItemPath))
if !found || currentItem.Kind != "item" {
return MoveProjectItemResult{}, ErrProjectItemNotFound
}
movedItemStableID := currentItem.ID
providedItemStableID := strings.TrimSpace(input.ItemStableID)
if providedItemStableID != "" && providedItemStableID != movedItemStableID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
parentOrderID := ""
trimmedParentFolderPath := strings.TrimSpace(input.ParentFolderPath)
providedParentStableID := strings.TrimSpace(input.ParentStableID)
if trimmedParentFolderPath != "" {
parentFolder, found := findProjectTreeFolderByPath(currentNodes, trimmedParentFolderPath)
if !found {
return MoveProjectItemResult{}, ErrProjectFolderNotFound
}
parentOrderID = parentFolder.ID
if providedParentStableID != "" && providedParentStableID != parentOrderID {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
} else if providedParentStableID != "" {
return MoveProjectItemResult{}, ErrInvalidProjectItemMove
}
previousItemPath, movedItemPath, err := service.moveProjectTreeItemOnDisk(project.Slug, input.ItemPath, input.ParentFolderPath)
if err != nil {
return MoveProjectItemResult{}, err
}
if err := service.rebuildProjection(ctx); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err := service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found := findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from projection")
}
if previousItemPath != movedItemPath {
if _, found := findProjectTreeNodeByPath(nodes, previousItemPath); found {
return MoveProjectItemResult{}, fmt.Errorf("previous project item path still present in projection")
}
}
folderOrder := service.readProjectFolderOrder(project.Slug, rootPath(project.Slug))
seedProjectTreeOrderParent(folderOrder, currentNodes, parentOrderID)
removeFolderOrderReference(folderOrder, movedItemStableID)
removeFolderOrderReference(folderOrder, movedItem.ID)
insertFolderOrder(folderOrder, parentOrderID, movedItem.ID, input.TargetIndex)
if err := service.writeProjectFolderOrder(project.Slug, rootPath(project.Slug), folderOrder); err != nil {
return MoveProjectItemResult{}, err
}
nodes, err = service.getProjectTreeNodesByRootPath(ctx, project.ID, rootPath)
if err != nil {
return MoveProjectItemResult{}, err
}
movedItem, found = findProjectTreeNodeByPath(nodes, movedItemPath)
if !found || movedItem.Kind != "item" {
return MoveProjectItemResult{}, fmt.Errorf("moved project item missing from ordered projection")
}
return MoveProjectItemResult{ProjectID: project.ID, PreviousItemStableID: movedItem.ID, PreviousItemPath: previousItemPath, MovedItem: movedItem, Nodes: nodes}, nil
}
+471
View File
@@ -0,0 +1,471 @@
// Path: Backend/internal/bootstrap/project_order.go
package bootstrap
import (
"fmt"
"path/filepath"
"sort"
"strings"
)
func (service *Service) readProjectFolderOrder(projectSlug, rootProjectionPath string) map[string][]string {
settingsPath := service.projectSettingsPath(projectSlug)
settingsPayload := readStructuredFileMap(settingsPath)
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
if folderOrderPayload == nil {
return map[string][]string{}
}
scopePayload, _ := folderOrderPayload[projectFolderOrderScope(rootProjectionPath)].(map[string]any)
if scopePayload == nil {
return map[string][]string{}
}
byParentPayload, _ := scopePayload["byParent"].(map[string]any)
if byParentPayload == nil {
return map[string][]string{}
}
order := make(map[string][]string, len(byParentPayload))
for key, raw := range byParentPayload {
for _, id := range stringSliceValue(raw) {
trimmedID := strings.TrimSpace(id)
if trimmedID == "" || slicesContains(order[key], trimmedID) {
continue
}
order[key] = append(order[key], trimmedID)
}
}
return order
}
func (service *Service) writeProjectFolderOrder(projectSlug, rootProjectionPath string, folderOrder map[string][]string) error {
settingsPath := service.projectSettingsPath(projectSlug)
settingsPayload := readStructuredFileMap(settingsPath)
if settingsPayload == nil {
settingsPayload = map[string]any{}
}
folderOrderPayload, _ := settingsPayload["folderOrder"].(map[string]any)
if folderOrderPayload == nil {
folderOrderPayload = map[string]any{}
}
scopeKey := projectFolderOrderScope(rootProjectionPath)
scopePayload, _ := folderOrderPayload[scopeKey].(map[string]any)
if scopePayload == nil {
scopePayload = map[string]any{}
}
byParentPayload := map[string]any{}
for key, ids := range folderOrder {
if len(ids) == 0 {
continue
}
copied := make([]string, 0, len(ids))
for _, id := range ids {
trimmedID := strings.TrimSpace(id)
if trimmedID == "" || slicesContains(copied, trimmedID) {
continue
}
copied = append(copied, trimmedID)
}
if len(copied) > 0 {
byParentPayload[key] = copied
}
}
scopePayload["byParent"] = byParentPayload
folderOrderPayload[scopeKey] = scopePayload
settingsPayload["folderOrder"] = folderOrderPayload
if err := writeCBORFile(settingsPath, settingsPayload); err != nil {
return fmt.Errorf("write project %s: %w", posixSettingsFileName, err)
}
return nil
}
func (service *Service) projectSettingsPath(projectSlug string) string {
return filepath.Join(strings.TrimSpace(service.posixRoot), "projects", slugDir("project", projectSlug), posixSettingsFileName)
}
func projectFolderOrderScope(rootProjectionPath string) string {
if strings.HasSuffix(rootProjectionPath, "/tree") {
return projectFolderOrderTree
}
return projectFolderOrderHierarchy
}
func applyProjectHierarchyFolderOrdering(folders []ProjectHierarchyFolderRecord, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
return applyProjectHierarchyFolderOrderingForParent(folders, "", folderOrder)
}
func applyProjectHierarchyFolderOrderingForParent(folders []ProjectHierarchyFolderRecord, parentID string, folderOrder map[string][]string) []ProjectHierarchyFolderRecord {
if len(folders) == 0 {
return folders
}
nextFolders := make([]ProjectHierarchyFolderRecord, len(folders))
copy(nextFolders, folders)
for index := range nextFolders {
nextFolders[index].Children = applyProjectHierarchyFolderOrderingForParent(nextFolders[index].Children, nextFolders[index].ID, folderOrder)
}
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 {
return nextFolders
}
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs {
if _, exists := rankByID[id]; !exists {
rankByID[id] = index
}
}
sort.SliceStable(nextFolders, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextFolders[left].ID]
rightRank, rightOrdered := rankByID[nextFolders[right].ID]
if leftOrdered && rightOrdered {
return leftRank < rightRank
}
if leftOrdered != rightOrdered {
return leftOrdered
}
return false
})
return nextFolders
}
func removeFolderOrder(folderOrder map[string][]string, folderID string) {
removeFolderOrderReference(folderOrder, folderID)
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
delete(folderOrder, projectFolderOrderParentKey(trimmedFolderID))
}
func removeFolderOrderReference(folderOrder map[string][]string, folderID string) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
for key, ids := range folderOrder {
nextIDs := ids[:0]
for _, id := range ids {
if strings.TrimSpace(id) == trimmedFolderID {
continue
}
nextIDs = append(nextIDs, id)
}
if len(nextIDs) == 0 {
delete(folderOrder, key)
continue
}
folderOrder[key] = append([]string(nil), nextIDs...)
}
}
func insertFolderOrder(folderOrder map[string][]string, parentID, folderID string, index int) {
trimmedFolderID := strings.TrimSpace(folderID)
if trimmedFolderID == "" {
return
}
removeFolderOrderReference(folderOrder, trimmedFolderID)
parentKey := projectFolderOrderParentKey(parentID)
children := append([]string(nil), folderOrder[parentKey]...)
if index < 0 {
index = 0
}
if index > len(children) {
index = len(children)
}
children = slicesInsert(children, index, trimmedFolderID)
folderOrder[parentKey] = children
}
func seedFolderOrderParent(folderOrder map[string][]string, folders []ProjectHierarchyFolderRecord, parentID string) {
children := folders
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectHierarchyFolder(folders, trimmedParentID)
if !found {
return
}
children = parent.Children
}
parentKey := projectFolderOrderParentKey(trimmedParentID)
seeded := make([]string, 0, len(children))
for _, child := range children {
childID := strings.TrimSpace(child.ID)
if childID == "" || slicesContains(seeded, childID) {
continue
}
seeded = append(seeded, childID)
}
if len(seeded) == 0 {
delete(folderOrder, parentKey)
return
}
folderOrder[parentKey] = seeded
}
func folderOrderChildren(folderOrder map[string][]string, parentID string) []string {
return append([]string(nil), folderOrder[projectFolderOrderParentKey(parentID)]...)
}
func projectFolderOrderParentKey(parentID string) string {
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID == "" {
return projectFolderOrderRootKey
}
return trimmedParentID
}
func stringSliceValue(value any) []string {
items, ok := value.([]any)
if !ok {
if typed, ok := value.([]string); ok {
return typed
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok {
result = append(result, text)
}
}
return result
}
func slicesContains(values []string, value string) bool {
for _, existing := range values {
if existing == value {
return true
}
}
return false
}
func slicesInsert(values []string, index int, value string) []string {
values = append(values, "")
copy(values[index+1:], values[index:])
values[index] = value
return values
}
func buildProjectHierarchyFolderTree(rows []projectHierarchyFolderRow, rootParentPath string) []ProjectHierarchyFolderRecord {
if len(rows) == 0 {
return nil
}
nodesByPath := make(map[string]*ProjectHierarchyFolderRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
folderID := strings.TrimSpace(row.ID)
if folderID == "" {
folderID = row.Path
}
label := strings.TrimSpace(row.Label)
if label == "" {
label = fallbackFolderLabel(row.Path)
}
nodesByPath[row.Path] = &ProjectHierarchyFolderRecord{ID: folderID, Path: row.Path, Label: label, Children: []ProjectHierarchyFolderRecord{}}
childrenByParent[row.ParentPath] = append(childrenByParent[row.ParentPath], row.Path)
}
var build func(parentPath string) []ProjectHierarchyFolderRecord
build = func(parentPath string) []ProjectHierarchyFolderRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 {
return nil
}
folders := make([]ProjectHierarchyFolderRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil {
continue
}
folder := ProjectHierarchyFolderRecord{ID: node.ID, Path: node.Path, Label: node.Label, Children: build(filepath.ToSlash(filepath.Join(childPath, "children")))}
folders = append(folders, folder)
}
return folders
}
return build(rootParentPath)
}
func buildProjectTreeNodeTree(rows []projectTreeNodeRow, rootParentPath string) []ProjectTreeNodeRecord {
if len(rows) == 0 {
return nil
}
nodesByPath := make(map[string]*ProjectTreeNodeRecord, len(rows))
childrenByParent := make(map[string][]string)
for _, row := range rows {
nodeKind := normalizeProjectTreeNodeKind(row.Kind)
nodeID := strings.TrimSpace(row.ID)
if nodeID == "" {
nodeID = row.Path
}
label := strings.TrimSpace(row.Label)
if label == "" {
if nodeKind == "item" {
label = fallbackItemLabel(row.Path)
} else {
label = fallbackFolderLabel(row.Path)
}
}
nodesByPath[row.Path] = &ProjectTreeNodeRecord{ID: nodeID, Path: row.Path, Label: label, Kind: nodeKind, ItemType: normalizeProjectTreeItemType(row.ItemType), Children: []ProjectTreeNodeRecord{}}
parentKey := normalizeProjectTreeParentPath(nodeKind, row.ParentPath)
childrenByParent[parentKey] = append(childrenByParent[parentKey], row.Path)
}
var build func(parentPath string) []ProjectTreeNodeRecord
build = func(parentPath string) []ProjectTreeNodeRecord {
childPaths := childrenByParent[parentPath]
if len(childPaths) == 0 {
return nil
}
nodes := make([]ProjectTreeNodeRecord, 0, len(childPaths))
for _, childPath := range childPaths {
node := nodesByPath[childPath]
if node == nil {
continue
}
nextNode := ProjectTreeNodeRecord{ID: node.ID, Path: node.Path, Label: node.Label, Kind: node.Kind, ItemType: node.ItemType}
if node.Kind == "folder" {
nextNode.Children = build(node.Path)
}
nodes = append(nodes, nextNode)
}
return nodes
}
return build(rootParentPath)
}
func normalizeProjectTreeParentPath(kind, parentPath string) string {
if kind == "folder" && strings.HasSuffix(parentPath, "/children") {
return filepath.ToSlash(filepath.Dir(parentPath))
}
return parentPath
}
func normalizeProjectTreeNodeKind(kind string) string {
switch strings.TrimSpace(kind) {
case "item":
return "item"
case "folder", "hierarchy_folder":
return "folder"
default:
return "folder"
}
}
func applyProjectTreeNodeOrdering(nodes []ProjectTreeNodeRecord, folderOrder map[string][]string) []ProjectTreeNodeRecord {
return applyProjectTreeNodeOrderingForParent(nodes, "", folderOrder)
}
func applyProjectTreeNodeOrderingForParent(nodes []ProjectTreeNodeRecord, parentID string, folderOrder map[string][]string) []ProjectTreeNodeRecord {
if len(nodes) == 0 {
return nodes
}
nextNodes := make([]ProjectTreeNodeRecord, len(nodes))
copy(nextNodes, nodes)
for index := range nextNodes {
if nextNodes[index].Kind == "folder" {
nextNodes[index].Children = applyProjectTreeNodeOrderingForParent(nextNodes[index].Children, nextNodes[index].ID, folderOrder)
}
}
orderIDs := folderOrder[projectFolderOrderParentKey(parentID)]
if len(orderIDs) == 0 {
return nextNodes
}
rankByID := make(map[string]int, len(orderIDs))
for index, id := range orderIDs {
if _, exists := rankByID[id]; !exists {
rankByID[id] = index
}
}
sort.SliceStable(nextNodes, func(left, right int) bool {
leftRank, leftOrdered := rankByID[nextNodes[left].ID]
rightRank, rightOrdered := rankByID[nextNodes[right].ID]
if leftOrdered && rightOrdered {
return leftRank < rightRank
}
if leftOrdered != rightOrdered {
return leftOrdered
}
return false
})
return nextNodes
}
func findProjectHierarchyFolder(folders []ProjectHierarchyFolderRecord, folderID string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.ID == folderID {
return folder, true
}
if child, ok := findProjectHierarchyFolder(folder.Children, folderID); ok {
return child, true
}
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectHierarchyFolderByPath(folders []ProjectHierarchyFolderRecord, folderPath string) (ProjectHierarchyFolderRecord, bool) {
for _, folder := range folders {
if folder.Path == folderPath {
return folder, true
}
if child, ok := findProjectHierarchyFolderByPath(folder.Children, folderPath); ok {
return child, true
}
}
return ProjectHierarchyFolderRecord{}, false
}
func findProjectTreeNode(nodes []ProjectTreeNodeRecord, nodeID string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.ID == nodeID {
return node, true
}
if child, ok := findProjectTreeNode(node.Children, nodeID); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeNodeByPath(nodes []ProjectTreeNodeRecord, nodePath string) (ProjectTreeNodeRecord, bool) {
for _, node := range nodes {
if node.Path == nodePath {
return node, true
}
if child, ok := findProjectTreeNodeByPath(node.Children, nodePath); ok {
return child, true
}
}
return ProjectTreeNodeRecord{}, false
}
func findProjectTreeFolderByPath(nodes []ProjectTreeNodeRecord, folderPath string) (ProjectTreeNodeRecord, bool) {
node, ok := findProjectTreeNodeByPath(nodes, folderPath)
if !ok || node.Kind != "folder" {
return ProjectTreeNodeRecord{}, false
}
return node, true
}
func seedProjectTreeOrderParent(folderOrder map[string][]string, nodes []ProjectTreeNodeRecord, parentID string) {
children := nodes
trimmedParentID := strings.TrimSpace(parentID)
if trimmedParentID != "" {
parent, found := findProjectTreeNode(nodes, trimmedParentID)
if !found || parent.Kind != "folder" {
return
}
children = parent.Children
}
parentKey := projectFolderOrderParentKey(parentID)
if len(folderOrder[parentKey]) > 0 {
return
}
orderedIDs := make([]string, 0, len(children))
for _, child := range children {
trimmedChildID := strings.TrimSpace(child.ID)
if trimmedChildID == "" || slicesContains(orderedIDs, trimmedChildID) {
continue
}
orderedIDs = append(orderedIDs, trimmedChildID)
}
if len(orderedIDs) > 0 {
folderOrder[parentKey] = orderedIDs
}
}
@@ -0,0 +1,346 @@
// Path: Backend/internal/bootstrap/project_queries.go
package bootstrap
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
func (service *Service) loadPrimaryOrganization(ctx context.Context) (*OrganizationRecord, error) {
var record OrganizationRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryOrganizationSlug).Scan(&record.ID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryDepartment(ctx context.Context) (*DepartmentRecord, error) {
var record DepartmentRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryDepartmentSlug).Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryTeam(ctx context.Context) (*TeamRecord, error) {
var record TeamRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryTeamSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) loadPrimaryProject(ctx context.Context) (*ProjectRecord, error) {
var record ProjectRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY CASE WHEN slug = $1 THEN 0 ELSE 1 END, created_at ASC
LIMIT 1;
`, primaryProjectSlug).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func (service *Service) listOrganizations(ctx context.Context) ([]OrganizationRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, name, slug
FROM organizations
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []OrganizationRecord
for rows.Next() {
var record OrganizationRecord
if err := rows.Scan(&record.ID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listDepartments(ctx context.Context) ([]DepartmentRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug
FROM departments
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []DepartmentRecord
for rows.Next() {
var record DepartmentRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listTeams(ctx context.Context) ([]TeamRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, name, slug
FROM teams
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TeamRecord
for rows.Next() {
var record TeamRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) listProjects(ctx context.Context) ([]ProjectRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ProjectRecord
for rows.Next() {
var record ProjectRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (service *Service) loadProjectByID(ctx context.Context, projectID string) (*ProjectRecord, error) {
var record ProjectRecord
err := service.db.Pool.QueryRow(ctx, `
SELECT id::text, organization_id::text, department_id::text, team_id::text, name, slug
FROM projects
WHERE id = $1::uuid
LIMIT 1;
`, projectID).Scan(&record.ID, &record.OrganizationID, &record.DepartmentID, &record.TeamID, &record.Name, &record.Slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrProjectNotFound
}
return nil, err
}
return &record, nil
}
func (service *Service) GetProjectHierarchyFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) {
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectHierarchyRootPath)
}
func (service *Service) GetProjectTreeFolders(ctx context.Context, projectID string) ([]ProjectHierarchyFolderRecord, error) {
return service.getProjectHierarchyFoldersByRootPath(ctx, projectID, projectTreeRootPath)
}
func (service *Service) GetProjectTreeNodes(ctx context.Context, projectID string) ([]ProjectTreeNodeRecord, error) {
return service.getProjectTreeNodesByRootPath(ctx, projectID, projectTreeRootPath)
}
func (service *Service) getProjectHierarchyFoldersByRootPath(
ctx context.Context,
projectID string,
rootPath func(projectSlug string) string,
) ([]ProjectHierarchyFolderRecord, error) {
project, err := service.loadProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
rootParentPath := rootPath(project.Slug)
rows, err := service.db.Pool.Query(ctx, `
SELECT
COALESCE(folder_meta.resource_id, ''),
directories.path,
COALESCE(directories.parent_path, ''),
COALESCE(folder_meta.resource_name, directories.resource_name, '')
FROM posix_nodes AS directories
LEFT JOIN posix_nodes AS folder_meta
ON folder_meta.path = directories.path || '/folder.cbor'
AND folder_meta.node_kind = 'file'::posix_node_kind
WHERE directories.node_kind = 'directory'::posix_node_kind
AND directories.logical_type = 'hierarchy_folder'
AND directories.project_slug = $1
AND directories.path LIKE $2
ORDER BY directories.depth ASC, directories.path ASC;
`, project.Slug, rootParentPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
var folderRows []projectHierarchyFolderRow
for rows.Next() {
var row projectHierarchyFolderRow
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label); err != nil {
return nil, err
}
folderRows = append(folderRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
folders := buildProjectHierarchyFolderTree(folderRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
return applyProjectHierarchyFolderOrdering(folders, folderOrder), nil
}
func (service *Service) getProjectTreeNodesByRootPath(
ctx context.Context,
projectID string,
rootPath func(projectSlug string) string,
) ([]ProjectTreeNodeRecord, error) {
project, err := service.loadProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
rootParentPath := rootPath(project.Slug)
rows, err := service.db.Pool.Query(ctx, `
SELECT
COALESCE(node_meta.resource_id, ''),
directories.path,
COALESCE(directories.parent_path, ''),
COALESCE(node_meta.resource_name, directories.resource_name, ''),
directories.logical_type,
COALESCE(node_meta.content_json->>'type', directories.content_json->>'type', '')
FROM posix_nodes AS directories
LEFT JOIN posix_nodes AS node_meta
ON node_meta.node_kind = 'file'::posix_node_kind
AND (
(directories.logical_type = 'hierarchy_folder' AND node_meta.path = directories.path || '/folder.cbor')
OR (directories.logical_type = 'item' AND node_meta.path = directories.path || '/item.cbor')
)
WHERE directories.node_kind = 'directory'::posix_node_kind
AND directories.logical_type IN ('hierarchy_folder', 'item')
AND directories.project_slug = $1
AND directories.path LIKE $2
ORDER BY directories.depth ASC, directories.path ASC;
`, project.Slug, rootParentPath+"/%")
if err != nil {
return nil, err
}
defer rows.Close()
var nodeRows []projectTreeNodeRow
for rows.Next() {
var row projectTreeNodeRow
if err := rows.Scan(&row.ID, &row.Path, &row.ParentPath, &row.Label, &row.Kind, &row.ItemType); err != nil {
return nil, err
}
nodeRows = append(nodeRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
nodes := buildProjectTreeNodeTree(nodeRows, rootParentPath)
folderOrder := service.readProjectFolderOrder(project.Slug, rootParentPath)
return applyProjectTreeNodeOrdering(nodes, folderOrder), nil
}
func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, error) {
rows, err := service.db.Pool.Query(ctx, `
SELECT id::text, organization_id::text, name, slug, kind::text, department_id::text, team_id::text, project_id::text
FROM workspaces
ORDER BY created_at ASC;
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []WorkspaceRecord
for rows.Next() {
var record WorkspaceRecord
if err := rows.Scan(&record.ID, &record.OrganizationID, &record.Name, &record.Slug, &record.Kind, &record.DepartmentID, &record.TeamID, &record.ProjectID); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
File diff suppressed because it is too large Load Diff
+91 -35
View File
@@ -7,6 +7,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
) )
type fakeRow struct { type fakeRow struct {
@@ -104,25 +107,28 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
} }
requiredPaths := []string{ requiredPaths := []string{
filepath.Join(rootPath, "settings.json"), filepath.Join(rootPath, posixSettingsFileName),
filepath.Join(rootPath, "layout.json"), filepath.Join(rootPath, posixLayoutFileName),
filepath.Join(rootPath, "catalog", "packs"), filepath.Join(rootPath, "catalog", "packs"),
filepath.Join(rootPath, "catalog", "standalone"), filepath.Join(rootPath, "catalog", "standalone"),
filepath.Join(rootPath, "departments", "department-primary-department", "settings.json"), filepath.Join(rootPath, "catalog", "packs", "pack-core", posixManifestFileName),
filepath.Join(rootPath, "departments", "department-primary-department", "users.json"), filepath.Join(rootPath, "catalog", "packs", "pack-core", "entries", "app-shell", posixManifestFileName),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "settings.json"), filepath.Join(rootPath, "catalog", "standalone", "app-shell", posixManifestFileName),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "users.json"), filepath.Join(rootPath, "departments", "department-primary-department", posixSettingsFileName),
filepath.Join(rootPath, "projects", "project-primary-project", "settings.json"), filepath.Join(rootPath, "departments", "department-primary-department", posixUsersFileName),
filepath.Join(rootPath, "projects", "project-primary-project", "home.json"), filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", posixSettingsFileName),
filepath.Join(rootPath, "projects", "project-primary-project", "acl.json"), filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", posixUsersFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixSettingsFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixHomeFileName),
filepath.Join(rootPath, "projects", "project-primary-project", posixACLFileName),
filepath.Join(rootPath, "projects", "project-primary-project", "children"), filepath.Join(rootPath, "projects", "project-primary-project", "children"),
filepath.Join(rootPath, "projects", "project-primary-project", "tree"), filepath.Join(rootPath, "projects", "project-primary-project", "tree"),
filepath.Join(rootPath, "users", "settings.json"), filepath.Join(rootPath, "users", posixSettingsFileName),
filepath.Join(rootPath, "users", "data.json"), filepath.Join(rootPath, "users", posixDataFileName),
filepath.Join(rootPath, "users", "personals"), filepath.Join(rootPath, "users", "personals"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "settings.json"), filepath.Join(rootPath, "users", "personals", "personal-ronald", posixSettingsFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "layout.json"), filepath.Join(rootPath, "users", "personals", "personal-ronald", posixLayoutFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "home.json"), filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "tree"), filepath.Join(rootPath, "users", "personals", "personal-ronald", "tree"),
} }
@@ -132,10 +138,10 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
} }
} }
settingsPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "settings.json")) settingsPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, posixSettingsFileName))
installationPayload, ok := settingsPayload["installation"].(map[string]any) installationPayload, ok := settingsPayload["installation"].(map[string]any)
if !ok { if !ok {
t.Fatalf("settings.json missing installation object: %#v", settingsPayload) t.Fatalf("%s missing installation object: %#v", posixSettingsFileName, settingsPayload)
} }
if installationPayload["name"] != "MangoPig" { if installationPayload["name"] != "MangoPig" {
t.Fatalf("expected installation name MangoPig, got %#v", installationPayload["name"]) t.Fatalf("expected installation name MangoPig, got %#v", installationPayload["name"])
@@ -144,31 +150,53 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
t.Fatalf("expected installation to be bootstrapped, got %#v", installationPayload["isBootstrapped"]) t.Fatalf("expected installation to be bootstrapped, got %#v", installationPayload["isBootstrapped"])
} }
layoutPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "layout.json")) layoutPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, posixLayoutFileName))
homePayload, ok := layoutPayload["home"].(map[string]any) homePayload, ok := layoutPayload["home"].(map[string]any)
if !ok { if !ok {
t.Fatalf("layout.json missing home object: %#v", layoutPayload) t.Fatalf("%s missing home object: %#v", posixLayoutFileName, layoutPayload)
} }
if homePayload["defaultProjectSlug"] != "primary-project" { if homePayload["defaultProjectSlug"] != "primary-project" {
t.Fatalf("expected default project slug primary-project, got %#v", homePayload["defaultProjectSlug"]) t.Fatalf("expected default project slug primary-project, got %#v", homePayload["defaultProjectSlug"])
} }
projectSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", "settings.json")) packManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "packs", "pack-core", posixManifestFileName))
if packManifest["type"] != "pack" {
t.Fatalf("expected pack manifest type pack, got %#v", packManifest["type"])
}
if packManifest["slug"] != "core" {
t.Fatalf("expected pack manifest slug core, got %#v", packManifest["slug"])
}
entryManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "packs", "pack-core", "entries", "app-shell", posixManifestFileName))
runtimePayload, ok := entryManifest["runtime"].(map[string]any)
if !ok {
t.Fatalf("%s missing runtime object: %#v", posixManifestFileName, entryManifest)
}
if runtimePayload["path"] != "/v1/app-shell" {
t.Fatalf("expected app-shell runtime path /v1/app-shell, got %#v", runtimePayload["path"])
}
standaloneManifest := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "catalog", "standalone", "app-shell", posixManifestFileName))
if standaloneManifest["source"] != "standalone" {
t.Fatalf("expected standalone manifest source standalone, got %#v", standaloneManifest["source"])
}
projectSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", posixSettingsFileName))
if projectSettings["type"] != "project" { if projectSettings["type"] != "project" {
t.Fatalf("expected project settings type project, got %#v", projectSettings["type"]) t.Fatalf("expected project settings type project, got %#v", projectSettings["type"])
} }
projectACL := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", "acl.json")) projectACL := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", posixACLFileName))
if projectACL["inherits"] != true { if projectACL["inherits"] != true {
t.Fatalf("expected project acl to inherit by default, got %#v", projectACL["inherits"]) t.Fatalf("expected project acl to inherit by default, got %#v", projectACL["inherits"])
} }
usersSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "settings.json")) usersSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", posixSettingsFileName))
if usersSettings["primaryAdminId"] != "admin-1" { if usersSettings["primaryAdminId"] != "admin-1" {
t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"]) t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"])
} }
personalSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", "settings.json")) personalSettings := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixSettingsFileName))
if personalSettings["type"] != "personal" { if personalSettings["type"] != "personal" {
t.Fatalf("expected personal settings type personal, got %#v", personalSettings["type"]) t.Fatalf("expected personal settings type personal, got %#v", personalSettings["type"])
} }
@@ -179,7 +207,7 @@ func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
t.Fatalf("expected personal slug ronald, got %#v", personalSettings["slug"]) t.Fatalf("expected personal slug ronald, got %#v", personalSettings["slug"])
} }
personalHome := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", "home.json")) personalHome := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName))
if personalHome["type"] != "personal-home" { if personalHome["type"] != "personal-home" {
t.Fatalf("expected personal home type personal-home, got %#v", personalHome["type"]) t.Fatalf("expected personal home type personal-home, got %#v", personalHome["type"])
} }
@@ -217,8 +245,8 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "children", "folder-design-system") createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "children", "folder-design-system")
for _, path := range []string{ for _, path := range []string{
filepath.Join(createdFolderPath, "folder.json"), filepath.Join(createdFolderPath, posixFolderFileName),
filepath.Join(createdFolderPath, "acl.json"), filepath.Join(createdFolderPath, posixACLFileName),
filepath.Join(createdFolderPath, "children"), filepath.Join(createdFolderPath, "children"),
} { } {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
@@ -226,7 +254,7 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing
} }
} }
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(createdFolderPath, "folder.json")) folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(createdFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" { if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"]) t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"])
} }
@@ -278,8 +306,8 @@ func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs") createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs")
for _, path := range []string{ for _, path := range []string{
filepath.Join(createdFolderPath, "folder.json"), filepath.Join(createdFolderPath, posixFolderFileName),
filepath.Join(createdFolderPath, "acl.json"), filepath.Join(createdFolderPath, posixACLFileName),
filepath.Join(createdFolderPath, "children"), filepath.Join(createdFolderPath, "children"),
} { } {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
@@ -345,7 +373,7 @@ func TestRenameProjectHierarchyFolderOnDiskRenamesFolderShape(t *testing.T) {
t.Fatalf("expected nested child folder to move with renamed parent: %v", err) t.Fatalf("expected nested child folder to move with renamed parent: %v", err)
} }
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, "folder.json")) folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" { if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"]) t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"])
} }
@@ -392,7 +420,7 @@ func TestRenameProjectTreeFolderOnDiskRenamesFolderShape(t *testing.T) {
t.Fatalf("unexpected renamed path: %s", renamedPath) t.Fatalf("unexpected renamed path: %s", renamedPath)
} }
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(renamedPath), "folder.json")) folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(renamedPath), posixFolderFileName))
if folderPayload["name"] != "Specifications" { if folderPayload["name"] != "Specifications" {
t.Fatalf("expected renamed folder name Specifications, got %#v", folderPayload["name"]) t.Fatalf("expected renamed folder name Specifications, got %#v", folderPayload["name"])
} }
@@ -454,7 +482,7 @@ func TestMoveProjectHierarchyFolderOnDiskMovesFolderToNewParent(t *testing.T) {
t.Fatalf("expected nested child folder to move with moved parent: %v", err) t.Fatalf("expected nested child folder to move with moved parent: %v", err)
} }
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(movedFolderPath, "folder.json")) folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(movedFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" { if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected moved folder to preserve stable id, got %#v", folderPayload["id"]) t.Fatalf("expected moved folder to preserve stable id, got %#v", folderPayload["id"])
} }
@@ -505,7 +533,7 @@ func TestMoveProjectTreeFolderOnDiskMovesFolderToNewParent(t *testing.T) {
t.Fatalf("unexpected moved path: %s", movedPath) t.Fatalf("unexpected moved path: %s", movedPath)
} }
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(movedPath), "folder.json")) folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(movedPath), posixFolderFileName))
if folderPayload["name"] != "Docs" { if folderPayload["name"] != "Docs" {
t.Fatalf("expected moved folder name Docs, got %#v", folderPayload["name"]) t.Fatalf("expected moved folder name Docs, got %#v", folderPayload["name"])
} }
@@ -622,7 +650,7 @@ func asStringForTest(value any) string {
return text return text
} }
func readJSONFileForTest[T any](t *testing.T, path string) T { func readStructuredFileForTest[T any](t *testing.T, path string) T {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -630,10 +658,38 @@ func readJSONFileForTest[T any](t *testing.T, path string) T {
t.Fatalf("read %s: %v", path, err) t.Fatalf("read %s: %v", path, err)
} }
var payload T var raw any
if err := json.Unmarshal(data, &payload); err != nil { switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
if err := cbor.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal %s: %v", path, err) t.Fatalf("unmarshal %s: %v", path, err)
} }
case ".json":
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
case ".jsonc":
ast, err := hujson.Parse(data)
if err != nil {
t.Fatalf("parse jsonc %s: %v", path, err)
}
ast.Standardize()
if err := json.Unmarshal(ast.Pack(), &raw); err != nil {
t.Fatalf("unmarshal standardized %s: %v", path, err)
}
default:
t.Fatalf("unsupported structured test file %s", path)
}
normalizedBytes, err := json.Marshal(normalizeStructuredValue(raw))
if err != nil {
t.Fatalf("normalize %s: %v", path, err)
}
var payload T
if err := json.Unmarshal(normalizedBytes, &payload); err != nil {
t.Fatalf("decode normalized %s: %v", path, err)
}
return payload return payload
} }
@@ -0,0 +1,127 @@
// Path: Backend/internal/httpx/api_project_decode.go
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
type createProjectFolderRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
}
type renameProjectFolderRequest struct {
FolderPath string `json:"folderId"`
Name string `json:"name"`
}
type deleteProjectFolderRequest struct {
FolderPath string `json:"folderId"`
}
// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
// names internally so the move flow is easier to reason about.
type moveProjectFolderRequest struct {
FolderPath string `json:"folderId"`
FolderStableID string `json:"folderNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
type createProjectItemRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
ItemType string `json:"itemType"`
}
type deleteProjectItemRequest struct {
ItemPath string `json:"itemId"`
}
type moveProjectItemRequest struct {
ItemPath string `json:"itemId"`
ItemStableID string `json:"itemNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{
FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
}
}
func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
return deleteProjectItemRequest{
ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
if !decodeJSONObjectBody(w, r, &payload) {
return payload, false
}
return payload, true
}
func decodeJSONObjectBody(w http.ResponseWriter, r *http.Request, target any) bool {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return false
}
return true
}
@@ -0,0 +1,42 @@
// Path: Backend/internal/httpx/api_project_errors.go
package httpx
import (
"errors"
"net/http"
bootstrapservice "moku-backend/internal/bootstrap"
)
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project folder."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message)
}
}
func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project tree."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
}
}
+2 -209
View File
@@ -1,9 +1,8 @@
// Path: Backend/internal/httpx/api_project_routes.go
package httpx package httpx
import ( import (
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"strings" "strings"
@@ -12,48 +11,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type createProjectFolderRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
}
type renameProjectFolderRequest struct {
FolderPath string `json:"folderId"`
Name string `json:"name"`
}
type deleteProjectFolderRequest struct {
FolderPath string `json:"folderId"`
}
// Keep the existing JSON contract for the frontend, but use clearer path-vs-stable-ID
// names internally so the move flow is easier to reason about.
type moveProjectFolderRequest struct {
FolderPath string `json:"folderId"`
FolderStableID string `json:"folderNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
type createProjectItemRequest struct {
Name string `json:"name"`
ParentFolderPath string `json:"parentFolderId"`
ItemType string `json:"itemType"`
}
type deleteProjectItemRequest struct {
ItemPath string `json:"itemId"`
}
type moveProjectItemRequest struct {
ItemPath string `json:"itemId"`
ItemStableID string `json:"itemNodeId"`
ParentFolderPath string `json:"parentFolderId"`
ParentStableID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleProjectFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" { if projectID == "" {
@@ -551,167 +508,3 @@ func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http
}, },
}) })
} }
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project folder", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project folder."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_folder_"+operation+"_failed", message)
}
}
func (routes apiRoutes) writeProjectTreeError(w http.ResponseWriter, r *http.Request, err error, operation string) {
switch {
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound), errors.Is(err, bootstrapservice.ErrProjectItemNotFound):
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error())
case errors.Is(err, bootstrapservice.ErrInvalidProjectFolderMove), errors.Is(err, bootstrapservice.ErrInvalidProjectItemMove):
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", err.Error())
default:
routes.cfg.Logger.Error(operation+" project tree", "error", err, "path", r.URL.Path)
message := "Failed to " + operation + " project tree."
if routes.cfg.Config.IsDevelopment() {
message = message + " " + err.Error()
}
WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "project_tree_"+operation+"_failed", message)
}
}
func decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{
FolderPath: strings.TrimSpace(r.URL.Query().Get("folderId")),
}
}
func decodeDeleteProjectItemRequest(r *http.Request) deleteProjectItemRequest {
return deleteProjectItemRequest{
ItemPath: strings.TrimSpace(r.URL.Query().Get("itemId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeProjectItemRequest(w http.ResponseWriter, r *http.Request) (createProjectItemRequest, bool) {
var payload createProjectItemRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeMoveProjectItemRequest(w http.ResponseWriter, r *http.Request) (moveProjectItemRequest, bool) {
var payload moveProjectItemRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/jobs/store.go
package jobs package jobs
import ( import (
+2 -451
View File
@@ -1,72 +1,15 @@
// Path: Backend/internal/posixproj/projector.go
package posixproj package posixproj
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"io/fs"
"os"
"path/filepath"
"strings" "strings"
"moku-backend/internal/database" "moku-backend/internal/database"
) )
const rootProjectionPath = "/"
type Projector struct {
db *database.DB
root string
}
type RebuildSummary struct {
TotalNodes int
DirectoryCount int
FileCount int
}
type NodeKind string
const (
NodeKindDirectory NodeKind = "directory"
NodeKindFile NodeKind = "file"
)
type Scope struct {
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
}
type Node struct {
Path string
ParentPath *string
Name string
Depth int
NodeKind NodeKind
LogicalType string
FileRole string
ResourceID string
ResourceName string
ResourceSlug string
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
ContentJSON []byte
SizeBytes int64
Checksum string
}
func NewProjector(db *database.DB, root string) *Projector { func NewProjector(db *database.DB, root string) *Projector {
return &Projector{db: db, root: strings.TrimSpace(root)} return &Projector{db: db, root: strings.TrimSpace(root)}
} }
@@ -159,395 +102,3 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
return summary, nil return summary, nil
} }
func ScanRoot(root string) ([]Node, error) {
rootPath := strings.TrimSpace(root)
if rootPath == "" {
return nil, nil
}
info, err := os.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat POSIX root: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
}
rootScope, err := loadRootScope(rootPath)
if err != nil {
return nil, err
}
nodes := []Node{{
Path: rootProjectionPath,
ParentPath: nil,
Name: filepath.Base(rootPath),
Depth: 0,
NodeKind: NodeKindDirectory,
LogicalType: "tenant_root",
InstallationID: rootScope.InstallationID,
OrganizationID: rootScope.OrganizationID,
OrganizationSlug: rootScope.OrganizationSlug,
}}
err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == rootPath {
return nil
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
relPath = filepath.ToSlash(relPath)
if relPath == "." {
return nil
}
node, err := buildNode(rootPath, relPath, entry, rootScope)
if err != nil {
return err
}
nodes = append(nodes, node)
return nil
})
if err != nil {
return nil, fmt.Errorf("scan POSIX root: %w", err)
}
return nodes, nil
}
func loadRootScope(rootPath string) (Scope, error) {
settingsPath := filepath.Join(rootPath, "settings.json")
content, err := os.ReadFile(settingsPath)
if err != nil {
if errorsIsNotExist(err) {
return Scope{}, nil
}
return Scope{}, fmt.Errorf("read root settings.json: %w", err)
}
var payload map[string]any
if err := json.Unmarshal(content, &payload); err != nil {
return Scope{}, fmt.Errorf("decode root settings.json: %w", err)
}
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
return Scope{
InstallationID: stringValue(installation["id"]),
OrganizationID: stringValue(organization["id"]),
OrganizationSlug: stringValue(organization["slug"]),
}, nil
}
func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
scope := deriveScope(relPath, rootScope)
parentPath := projectionParentPath(relPath)
logicalType, fileRole := classifyPath(relPath, entry.IsDir())
node := Node{
Path: relPath,
ParentPath: parentPath,
Name: entry.Name(),
Depth: strings.Count(relPath, "/") + 1,
NodeKind: NodeKindDirectory,
LogicalType: logicalType,
FileRole: fileRole,
InstallationID: scope.InstallationID,
OrganizationID: scope.OrganizationID,
OrganizationSlug: scope.OrganizationSlug,
DepartmentSlug: scope.DepartmentSlug,
TeamSlug: scope.TeamSlug,
ProjectSlug: scope.ProjectSlug,
PersonalSlug: scope.PersonalSlug,
}
if entry.IsDir() {
return node, nil
}
absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
content, err := os.ReadFile(absPath)
if err != nil {
return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
}
hash := sha256.Sum256(content)
node.NodeKind = NodeKindFile
node.SizeBytes = int64(len(content))
node.Checksum = hex.EncodeToString(hash[:])
if strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
var payload map[string]any
if err := json.Unmarshal(content, &payload); err == nil {
jsonContent, err := json.Marshal(payload)
if err != nil {
return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
}
node.ContentJSON = jsonContent
node.ResourceID = stringValue(payload["id"])
node.ResourceName = stringValue(payload["name"])
node.ResourceSlug = stringValue(payload["slug"])
if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
node.ResourceID = stringValue(installation["id"])
node.ResourceName = stringValue(installation["name"])
node.InstallationID = stringValue(installation["id"])
node.OrganizationID = stringValue(organization["id"])
node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
}
if node.ResourceID == "" && fileRole == "users" {
node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
}
}
}
if node.ResourceSlug == "" {
node.ResourceSlug = inferredResourceSlug(logicalType, scope)
}
return node, nil
}
func deriveScope(relPath string, rootScope Scope) Scope {
scope := rootScope
parts := strings.Split(relPath, "/")
for _, part := range parts {
switch {
case strings.HasPrefix(part, "department-"):
scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
case strings.HasPrefix(part, "team-"):
scope.TeamSlug = strings.TrimPrefix(part, "team-")
case strings.HasPrefix(part, "project-"):
scope.ProjectSlug = strings.TrimPrefix(part, "project-")
case strings.HasPrefix(part, "personal-"):
scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
}
}
return scope
}
func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
parts := strings.Split(relPath, "/")
name := parts[len(parts)-1]
if !isDir {
fileRole = strings.TrimSuffix(name, filepath.Ext(name))
}
hasChildrenAncestor := pathContainsSegment(parts, "children")
hasTreeAncestor := pathContainsSegment(parts, "tree")
parentName := ""
if len(parts) >= 2 {
parentName = parts[len(parts)-2]
}
switch {
case relPath == "settings.json":
return "tenant", "settings"
case relPath == "layout.json":
return "tenant", "layout"
case len(parts) >= 1 && parts[0] == "catalog":
if isDir {
if len(parts) == 1 {
return "catalog", ""
}
if len(parts) >= 2 && parts[1] == "packs" {
if len(parts) == 2 {
return "catalog_packs", ""
}
if len(parts) == 3 {
return "catalog_pack", ""
}
if len(parts) >= 4 && parts[3] == "entries" {
return "catalog_pack_entries", ""
}
return "catalog_entry", ""
}
if len(parts) >= 2 && parts[1] == "standalone" {
if len(parts) == 2 {
return "catalog_standalone", ""
}
return "catalog_entry", ""
}
}
return "catalog", fileRole
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
if isDir {
if len(parts) == 2 {
return "department", ""
}
if len(parts) == 3 && parts[2] == "teams" {
return "department_teams", ""
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", ""
}
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", fileRole
}
return "department", fileRole
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
if isDir {
if strings.HasPrefix(name, "project-") {
return "project", ""
}
if name == "children" {
return "project_children", ""
}
if name == "tree" {
return "project_tree", ""
}
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "item-") {
return "item", ""
}
}
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
if hasTreeAncestor {
if strings.HasPrefix(parentName, "item-") {
return "item", fileRole
}
if strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
}
return "project", fileRole
case len(parts) >= 1 && parts[0] == "users":
if isDir {
if len(parts) == 1 {
return "users", ""
}
if len(parts) == 2 && parts[1] == "personals" {
return "personals", ""
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
return "personal", ""
}
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
if strings.HasPrefix(name, "folder-") {
return "folder", ""
}
if strings.HasPrefix(name, "item-") {
return "item", ""
}
}
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
if strings.Contains(relPath, "/tree/") {
if strings.HasPrefix(parts[len(parts)-2], "item-") {
return "item", fileRole
}
if strings.HasPrefix(parts[len(parts)-2], "folder-") {
return "folder", fileRole
}
}
return "personal", fileRole
}
return "users", fileRole
default:
if isDir {
return "directory", ""
}
return "file", fileRole
}
}
func pathContainsSegment(parts []string, target string) bool {
for _, part := range parts {
if part == target {
return true
}
}
return false
}
func projectionParentPath(relPath string) *string {
if relPath == "" || relPath == rootProjectionPath {
return nil
}
parent := filepath.ToSlash(filepath.Dir(relPath))
if parent == "." || parent == "" {
root := rootProjectionPath
return &root
}
return &parent
}
func inferredResourceSlug(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func parentEntityName(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func stringValue(value any) string {
stringValue, _ := value.(string)
return strings.TrimSpace(stringValue)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func summarizeNodes(nodes []Node) RebuildSummary {
summary := RebuildSummary{TotalNodes: len(nodes)}
for _, node := range nodes {
switch node.NodeKind {
case NodeKindDirectory:
summary.DirectoryCount++
case NodeKindFile:
summary.FileCount++
}
}
return summary
}
func errorsIsNotExist(err error) bool {
return err != nil && os.IsNotExist(err)
}
@@ -0,0 +1,227 @@
// Path: Backend/internal/posixproj/projector_classify.go
package posixproj
import (
"path/filepath"
"strings"
)
func deriveScope(relPath string, rootScope Scope) Scope {
scope := rootScope
parts := strings.Split(relPath, "/")
for _, part := range parts {
switch {
case strings.HasPrefix(part, "department-"):
scope.DepartmentSlug = strings.TrimPrefix(part, "department-")
case strings.HasPrefix(part, "team-"):
scope.TeamSlug = strings.TrimPrefix(part, "team-")
case strings.HasPrefix(part, "project-"):
scope.ProjectSlug = strings.TrimPrefix(part, "project-")
case strings.HasPrefix(part, "personal-"):
scope.PersonalSlug = strings.TrimPrefix(part, "personal-")
}
}
return scope
}
func classifyPath(relPath string, isDir bool) (logicalType, fileRole string) {
parts := strings.Split(relPath, "/")
name := parts[len(parts)-1]
ext := strings.ToLower(filepath.Ext(name))
structuredRole := strings.TrimSuffix(name, filepath.Ext(name))
if !isDir {
fileRole = structuredRole
}
hasChildrenAncestor := pathContainsSegment(parts, "children")
hasTreeAncestor := pathContainsSegment(parts, "tree")
parentName := ""
if len(parts) >= 2 {
parentName = parts[len(parts)-2]
}
switch {
case !isDir && structuredRole == "settings" && len(parts) == 1 && ext == ".cbor":
return "tenant", "settings"
case !isDir && structuredRole == "layout" && len(parts) == 1 && ext == ".cbor":
return "tenant", "layout"
case len(parts) >= 1 && parts[0] == "catalog":
if isDir {
if len(parts) == 1 {
return "catalog", ""
}
if len(parts) >= 2 && parts[1] == "packs" {
if len(parts) == 2 {
return "catalog_packs", ""
}
if len(parts) == 3 {
return "catalog_pack", ""
}
if len(parts) >= 4 && parts[3] == "entries" {
return "catalog_pack_entries", ""
}
return "catalog_entry", ""
}
if len(parts) >= 2 && parts[1] == "standalone" {
if len(parts) == 2 {
return "catalog_standalone", ""
}
return "catalog_entry", ""
}
}
return "catalog", fileRole
case len(parts) >= 2 && parts[0] == "departments" && strings.HasPrefix(parts[1], "department-"):
if isDir {
if len(parts) == 2 {
return "department", ""
}
if len(parts) == 3 && parts[2] == "teams" {
return "department_teams", ""
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", ""
}
}
if len(parts) >= 4 && strings.HasPrefix(parts[3], "team-") {
return "team", fileRole
}
return "department", fileRole
case len(parts) >= 2 && parts[0] == "projects" && strings.HasPrefix(parts[1], "project-"):
if isDir {
if strings.HasPrefix(name, "project-") {
return "project", ""
}
if name == "children" {
return "project_children", ""
}
if name == "tree" {
return "project_tree", ""
}
if hasChildrenAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "folder-") {
return "hierarchy_folder", ""
}
if hasTreeAncestor && strings.HasPrefix(name, "item-") {
return "item", ""
}
}
if hasChildrenAncestor && strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
if hasTreeAncestor {
if strings.HasPrefix(parentName, "item-") {
return "item", fileRole
}
if strings.HasPrefix(parentName, "folder-") {
return "hierarchy_folder", fileRole
}
}
return "project", fileRole
case len(parts) >= 1 && parts[0] == "users":
if isDir {
if len(parts) == 1 {
return "users", ""
}
if len(parts) == 2 && parts[1] == "personals" {
return "personals", ""
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
return "personal", ""
}
if strings.Contains(relPath, "/tree/") || strings.HasSuffix(relPath, "/tree") {
if strings.HasPrefix(name, "folder-") {
return "folder", ""
}
if strings.HasPrefix(name, "item-") {
return "item", ""
}
}
}
if len(parts) >= 3 && parts[1] == "personals" && strings.HasPrefix(parts[2], "personal-") {
if strings.Contains(relPath, "/tree/") {
if strings.HasPrefix(parts[len(parts)-2], "item-") {
return "item", fileRole
}
if strings.HasPrefix(parts[len(parts)-2], "folder-") {
return "folder", fileRole
}
}
return "personal", fileRole
}
return "users", fileRole
default:
if isDir {
return "directory", ""
}
return "file", fileRole
}
}
func pathContainsSegment(parts []string, target string) bool {
for _, part := range parts {
if part == target {
return true
}
}
return false
}
func projectionParentPath(relPath string) *string {
if relPath == "" || relPath == rootProjectionPath {
return nil
}
parent := filepath.ToSlash(filepath.Dir(relPath))
if parent == "." || parent == "" {
root := rootProjectionPath
return &root
}
return &parent
}
func inferredResourceSlug(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func parentEntityName(logicalType string, scope Scope) string {
switch logicalType {
case "department":
return scope.DepartmentSlug
case "team":
return scope.TeamSlug
case "project":
return scope.ProjectSlug
case "personal":
return scope.PersonalSlug
default:
return ""
}
}
func stringValue(value any) string {
stringValue, _ := value.(string)
return strings.TrimSpace(stringValue)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
@@ -0,0 +1,277 @@
// Path: Backend/internal/posixproj/projector_scan.go
package posixproj
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
)
const (
settingsCBORPath = "settings.cbor"
)
func ScanRoot(root string) ([]Node, error) {
rootPath := strings.TrimSpace(root)
if rootPath == "" {
return nil, nil
}
info, err := os.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat POSIX root: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath)
}
rootScope, err := loadRootScope(rootPath)
if err != nil {
return nil, err
}
nodes := []Node{{
Path: rootProjectionPath,
ParentPath: nil,
Name: filepath.Base(rootPath),
Depth: 0,
NodeKind: NodeKindDirectory,
LogicalType: "tenant_root",
InstallationID: rootScope.InstallationID,
OrganizationID: rootScope.OrganizationID,
OrganizationSlug: rootScope.OrganizationSlug,
}}
err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == rootPath {
return nil
}
relPath, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
relPath = filepath.ToSlash(relPath)
if relPath == "." {
return nil
}
node, err := buildNode(rootPath, relPath, entry, rootScope)
if err != nil {
return err
}
nodes = append(nodes, node)
return nil
})
if err != nil {
return nil, fmt.Errorf("scan POSIX root: %w", err)
}
return nodes, nil
}
func loadRootScope(rootPath string) (Scope, error) {
settingsPath := filepath.Join(rootPath, settingsCBORPath)
content, err := os.ReadFile(settingsPath)
if err != nil {
if errorsIsNotExist(err) {
return Scope{}, nil
}
return Scope{}, fmt.Errorf("read root %s: %w", filepath.Base(settingsPath), err)
}
payload, err := decodeStructuredMap(settingsPath, content)
if err != nil {
return Scope{}, fmt.Errorf("decode root %s: %w", filepath.Base(settingsPath), err)
}
if len(payload) == 0 {
return Scope{}, nil
}
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
return Scope{
InstallationID: stringValue(installation["id"]),
OrganizationID: stringValue(organization["id"]),
OrganizationSlug: stringValue(organization["slug"]),
}, nil
}
func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) {
scope := deriveScope(relPath, rootScope)
parentPath := projectionParentPath(relPath)
logicalType, fileRole := classifyPath(relPath, entry.IsDir())
node := Node{
Path: relPath,
ParentPath: parentPath,
Name: entry.Name(),
Depth: strings.Count(relPath, "/") + 1,
NodeKind: NodeKindDirectory,
LogicalType: logicalType,
FileRole: fileRole,
InstallationID: scope.InstallationID,
OrganizationID: scope.OrganizationID,
OrganizationSlug: scope.OrganizationSlug,
DepartmentSlug: scope.DepartmentSlug,
TeamSlug: scope.TeamSlug,
ProjectSlug: scope.ProjectSlug,
PersonalSlug: scope.PersonalSlug,
}
if entry.IsDir() {
return node, nil
}
absPath := filepath.Join(rootPath, filepath.FromSlash(relPath))
content, err := os.ReadFile(absPath)
if err != nil {
return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err)
}
hash := sha256.Sum256(content)
node.NodeKind = NodeKindFile
node.SizeBytes = int64(len(content))
node.Checksum = hex.EncodeToString(hash[:])
if isStructuredProjectionFile(entry.Name()) {
payload, err := decodeStructuredMap(absPath, content)
if err == nil {
jsonContent, err := json.Marshal(payload)
if err != nil {
return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err)
}
node.ContentJSON = jsonContent
node.ResourceID = stringValue(payload["id"])
node.ResourceName = stringValue(payload["name"])
node.ResourceSlug = stringValue(payload["slug"])
if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" {
installation, _ := payload["installation"].(map[string]any)
organization, _ := payload["organization"].(map[string]any)
node.ResourceID = stringValue(installation["id"])
node.ResourceName = stringValue(installation["name"])
node.InstallationID = stringValue(installation["id"])
node.OrganizationID = stringValue(organization["id"])
node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"]))
}
if node.ResourceID == "" && fileRole == "users" {
node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope))
}
}
}
if node.ResourceSlug == "" {
node.ResourceSlug = inferredResourceSlug(logicalType, scope)
}
return node, nil
}
func isStructuredProjectionFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".json", ".jsonc", ".cbor":
return true
default:
return false
}
}
func decodeStructuredMap(path string, content []byte) (map[string]any, error) {
var payload any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
if err := cbor.Unmarshal(content, &payload); err != nil {
return nil, err
}
case ".json":
if err := json.Unmarshal(content, &payload); err != nil {
return nil, err
}
case ".jsonc":
decoded, err := decodeJSONCToAny(content)
if err != nil {
return nil, err
}
payload = decoded
default:
return nil, fmt.Errorf("unsupported structured file extension %q", filepath.Ext(path))
}
if payload == nil {
return map[string]any{}, nil
}
normalized, ok := normalizeStructuredValue(payload).(map[string]any)
if !ok || normalized == nil {
return map[string]any{}, nil
}
return normalized, nil
}
func decodeJSONCToAny(content []byte) (any, error) {
ast, err := hujson.Parse(content)
if err != nil {
return nil, err
}
ast.Standardize()
standardized := ast.Pack()
var payload any
if err := json.Unmarshal(standardized, &payload); err != nil {
return nil, err
}
return payload, nil
}
func normalizeStructuredValue(value any) any {
switch typed := value.(type) {
case map[string]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[key] = normalizeStructuredValue(child)
}
return normalized
case map[any]any:
normalized := make(map[string]any, len(typed))
for key, child := range typed {
normalized[fmt.Sprint(key)] = normalizeStructuredValue(child)
}
return normalized
case []any:
normalized := make([]any, len(typed))
for index, child := range typed {
normalized[index] = normalizeStructuredValue(child)
}
return normalized
default:
return value
}
}
func summarizeNodes(nodes []Node) RebuildSummary {
summary := RebuildSummary{TotalNodes: len(nodes)}
for _, node := range nodes {
switch node.NodeKind {
case NodeKindDirectory:
summary.DirectoryCount++
case NodeKindFile:
summary.FileCount++
}
}
return summary
}
func errorsIsNotExist(err error) bool {
return err != nil && os.IsNotExist(err)
}
+118 -34
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/posixproj/projector_test.go
package posixproj package posixproj
import ( import (
@@ -5,6 +7,21 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/fxamacker/cbor/v2"
)
const (
testSettingsFileName = "settings.cbor"
testLayoutFileName = "layout.cbor"
testHomeFileName = "home.cbor"
testUsersFileName = "users.cbor"
testACLFileName = "acl.cbor"
testFolderFileName = "folder.cbor"
testItemFileName = "item.cbor"
testDataFileName = "data.cbor"
testSchemaFileName = "schema.json"
testManifestFileName = "manifest.jsonc"
) )
func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) { func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
@@ -12,6 +29,8 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
mustMkdirAll(t, filepath.Join(root, "catalog", "packs")) mustMkdirAll(t, filepath.Join(root, "catalog", "packs"))
mustMkdirAll(t, filepath.Join(root, "catalog", "standalone")) mustMkdirAll(t, filepath.Join(root, "catalog", "standalone"))
mustMkdirAll(t, filepath.Join(root, "catalog", "packs", "pack-core", "entries", "app-shell"))
mustMkdirAll(t, filepath.Join(root, "catalog", "standalone", "app-shell"))
mustMkdirAll(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team")) mustMkdirAll(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "children")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "children"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "tree")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "tree"))
@@ -20,7 +39,7 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap")) mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap"))
mustMkdirAll(t, filepath.Join(root, "users", "personals")) mustMkdirAll(t, filepath.Join(root, "users", "personals"))
mustWriteJSON(t, filepath.Join(root, "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, testSettingsFileName), map[string]any{
"installation": map[string]any{ "installation": map[string]any{
"id": "installation-1", "id": "installation-1",
"name": "MangoPig", "name": "MangoPig",
@@ -32,81 +51,103 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
"slug": "primary-organization", "slug": "primary-organization",
}, },
}) })
mustWriteJSON(t, filepath.Join(root, "layout.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, testLayoutFileName), map[string]any{
"type": "tenant-layout", "type": "tenant-layout",
"home": map[string]any{"defaultProjectSlug": "primary-project"}, "home": map[string]any{"defaultProjectSlug": "primary-project"},
}) })
mustWriteJSON(t, filepath.Join(root, "departments", "department-primary-department", "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "catalog", "packs", "pack-core", testManifestFileName), map[string]any{
"id": "pack-core",
"slug": "core",
"type": "pack",
"name": "Core Pack",
"entries": []map[string]any{{"slug": "app-shell", "path": "entries/app-shell"}},
})
mustWriteStructured(t, filepath.Join(root, "catalog", "packs", "pack-core", "entries", "app-shell", testManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"runtime": map[string]any{
"kind": "route",
"path": "/v1/app-shell",
},
})
mustWriteStructured(t, filepath.Join(root, "catalog", "standalone", "app-shell", testManifestFileName), map[string]any{
"id": "app-shell",
"slug": "app-shell",
"type": "app",
"source": "standalone",
})
mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", testSettingsFileName), map[string]any{
"id": "dept-1", "id": "dept-1",
"name": "Primary Department", "name": "Primary Department",
"slug": "primary-department", "slug": "primary-department",
"type": "department", "type": "department",
}) })
mustWriteJSON(t, filepath.Join(root, "departments", "department-primary-department", "users.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", testUsersFileName), map[string]any{
"users": []map[string]any{{"id": "admin-1"}}, "users": []map[string]any{{"id": "admin-1"}},
}) })
mustWriteJSON(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team", "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "departments", "department-primary-department", "teams", "team-primary-team", testSettingsFileName), map[string]any{
"id": "team-1", "id": "team-1",
"name": "Primary Team", "name": "Primary Team",
"slug": "primary-team", "slug": "primary-team",
"type": "team", "type": "team",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testSettingsFileName), map[string]any{
"id": "project-1", "id": "project-1",
"name": "Primary Project", "name": "Primary Project",
"slug": "primary-project", "slug": "primary-project",
"type": "project", "type": "project",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "home.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testHomeFileName), map[string]any{
"type": "project-home", "type": "project-home",
"project": "primary-project", "project": "primary-project",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "acl.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", testACLFileName), map[string]any{
"inherits": true, "inherits": true,
"rules": []any{}, "rules": []any{},
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "folder.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", testFolderFileName), map[string]any{
"name": "Design", "name": "Design",
"slug": "design", "slug": "design",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "acl.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", testACLFileName), map[string]any{
"inherits": true, "inherits": true,
"rules": []any{}, "rules": []any{},
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testSettingsFileName), map[string]any{
"id": "project-2", "id": "project-2",
"name": "Web Project", "name": "Web Project",
"slug": "web", "slug": "web",
"type": "project", "type": "project",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "home.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testHomeFileName), map[string]any{
"type": "project-home", "type": "project-home",
"project": "web", "project": "web",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "acl.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", testACLFileName), map[string]any{
"inherits": true, "inherits": true,
"rules": []any{}, "rules": []any{},
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "folder.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", testFolderFileName), map[string]any{
"name": "Docs", "name": "Docs",
"slug": "docs", "slug": "docs",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "item.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testItemFileName), map[string]any{
"id": "item-1", "id": "item-1",
"name": "Roadmap", "name": "Roadmap",
"slug": "roadmap", "slug": "roadmap",
"type": "board", "type": "board",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "schema.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testSchemaFileName), map[string]any{
"type": "object", "type": "object",
}) })
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "data.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testDataFileName), map[string]any{
"title": "Roadmap", "title": "Roadmap",
}) })
mustWriteJSON(t, filepath.Join(root, "users", "settings.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "users", testSettingsFileName), map[string]any{
"primaryAdminId": "admin-1", "primaryAdminId": "admin-1",
}) })
mustWriteJSON(t, filepath.Join(root, "users", "data.json"), map[string]any{ mustWriteStructured(t, filepath.Join(root, "users", testDataFileName), map[string]any{
"users": []map[string]any{{"id": "admin-1", "email": "ronald@example.com"}}, "users": []map[string]any{{"id": "admin-1", "email": "ronald@example.com"}},
}) })
@@ -131,7 +172,7 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("expected root organization slug primary-organization, got %q", rootNode.OrganizationSlug) t.Fatalf("expected root organization slug primary-organization, got %q", rootNode.OrganizationSlug)
} }
tenantSettings := index["settings.json"] tenantSettings := index[testSettingsFileName]
if tenantSettings.LogicalType != "tenant" || tenantSettings.FileRole != "settings" { if tenantSettings.LogicalType != "tenant" || tenantSettings.FileRole != "settings" {
t.Fatalf("unexpected tenant settings classification: %#v", tenantSettings) t.Fatalf("unexpected tenant settings classification: %#v", tenantSettings)
} }
@@ -139,7 +180,31 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("expected installation id installation-1, got %q", tenantSettings.InstallationID) t.Fatalf("expected installation id installation-1, got %q", tenantSettings.InstallationID)
} }
deptSettings := index["departments/department-primary-department/settings.json"] packManifest := index[filepath.ToSlash(filepath.Join("catalog", "packs", "pack-core", testManifestFileName))]
if packManifest.LogicalType != "catalog" || packManifest.FileRole != "manifest" {
t.Fatalf("unexpected pack manifest classification: %#v", packManifest)
}
if packManifest.ResourceSlug != "core" {
t.Fatalf("expected pack manifest resource slug core, got %q", packManifest.ResourceSlug)
}
entryManifest := index[filepath.ToSlash(filepath.Join("catalog", "packs", "pack-core", "entries", "app-shell", testManifestFileName))]
if entryManifest.LogicalType != "catalog" || entryManifest.FileRole != "manifest" {
t.Fatalf("unexpected catalog entry manifest classification: %#v", entryManifest)
}
if entryManifest.ResourceID != "app-shell" {
t.Fatalf("expected catalog entry manifest resource id app-shell, got %q", entryManifest.ResourceID)
}
standaloneManifest := index[filepath.ToSlash(filepath.Join("catalog", "standalone", "app-shell", testManifestFileName))]
if standaloneManifest.LogicalType != "catalog" || standaloneManifest.FileRole != "manifest" {
t.Fatalf("unexpected standalone manifest classification: %#v", standaloneManifest)
}
if standaloneManifest.ResourceSlug != "app-shell" {
t.Fatalf("expected standalone manifest resource slug app-shell, got %q", standaloneManifest.ResourceSlug)
}
deptSettings := index[filepath.ToSlash(filepath.Join("departments", "department-primary-department", testSettingsFileName))]
if deptSettings.DepartmentSlug != "primary-department" { if deptSettings.DepartmentSlug != "primary-department" {
t.Fatalf("expected department slug primary-department, got %q", deptSettings.DepartmentSlug) t.Fatalf("expected department slug primary-department, got %q", deptSettings.DepartmentSlug)
} }
@@ -147,12 +212,12 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("expected department resource id dept-1, got %q", deptSettings.ResourceID) t.Fatalf("expected department resource id dept-1, got %q", deptSettings.ResourceID)
} }
teamSettings := index["departments/department-primary-department/teams/team-primary-team/settings.json"] teamSettings := index[filepath.ToSlash(filepath.Join("departments", "department-primary-department", "teams", "team-primary-team", testSettingsFileName))]
if teamSettings.TeamSlug != "primary-team" { if teamSettings.TeamSlug != "primary-team" {
t.Fatalf("expected team slug primary-team, got %q", teamSettings.TeamSlug) t.Fatalf("expected team slug primary-team, got %q", teamSettings.TeamSlug)
} }
projectSettings := index["projects/project-primary-project/settings.json"] projectSettings := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", testSettingsFileName))]
if projectSettings.ProjectSlug != "primary-project" { if projectSettings.ProjectSlug != "primary-project" {
t.Fatalf("expected project slug primary-project, got %q", projectSettings.ProjectSlug) t.Fatalf("expected project slug primary-project, got %q", projectSettings.ProjectSlug)
} }
@@ -175,12 +240,12 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("unexpected hierarchy folder node: %#v", hierarchyFolder) t.Fatalf("unexpected hierarchy folder node: %#v", hierarchyFolder)
} }
hierarchyFolderACL := index["projects/project-primary-project/children/folder-design/acl.json"] hierarchyFolderACL := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "children", "folder-design", testACLFileName))]
if hierarchyFolderACL.LogicalType != "hierarchy_folder" || hierarchyFolderACL.FileRole != "acl" { if hierarchyFolderACL.LogicalType != "hierarchy_folder" || hierarchyFolderACL.FileRole != "acl" {
t.Fatalf("unexpected hierarchy folder acl classification: %#v", hierarchyFolderACL) t.Fatalf("unexpected hierarchy folder acl classification: %#v", hierarchyFolderACL)
} }
childProjectSettings := index["projects/project-primary-project/children/folder-design/children/project-web/settings.json"] childProjectSettings := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "children", "folder-design", "children", "project-web", testSettingsFileName))]
if childProjectSettings.LogicalType != "project" || childProjectSettings.ProjectSlug != "web" { if childProjectSettings.LogicalType != "project" || childProjectSettings.ProjectSlug != "web" {
t.Fatalf("unexpected child project classification: %#v", childProjectSettings) t.Fatalf("unexpected child project classification: %#v", childProjectSettings)
} }
@@ -190,12 +255,12 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("unexpected tree folder node: %#v", treeFolder) t.Fatalf("unexpected tree folder node: %#v", treeFolder)
} }
treeFolderACL := index["projects/project-primary-project/tree/folder-docs/folder.json"] treeFolderACL := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "tree", "folder-docs", testFolderFileName))]
if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" { if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" {
t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL) t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL)
} }
treeItem := index["projects/project-primary-project/tree/folder-docs/item-roadmap/item.json"] treeItem := index[filepath.ToSlash(filepath.Join("projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", testItemFileName))]
if treeItem.LogicalType != "item" || treeItem.FileRole != "item" { if treeItem.LogicalType != "item" || treeItem.FileRole != "item" {
t.Fatalf("unexpected tree item classification: %#v", treeItem) t.Fatalf("unexpected tree item classification: %#v", treeItem)
} }
@@ -203,12 +268,12 @@ func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
t.Fatalf("expected tree item resource slug roadmap, got %#v", treeItem.ResourceSlug) t.Fatalf("expected tree item resource slug roadmap, got %#v", treeItem.ResourceSlug)
} }
usersData := index["users/data.json"] usersData := index[filepath.ToSlash(filepath.Join("users", testDataFileName))]
if usersData.LogicalType != "users" || usersData.FileRole != "data" { if usersData.LogicalType != "users" || usersData.FileRole != "data" {
t.Fatalf("unexpected users data classification: %#v", usersData) t.Fatalf("unexpected users data classification: %#v", usersData)
} }
if usersData.Checksum == "" || usersData.SizeBytes == 0 { if usersData.Checksum == "" || usersData.SizeBytes == 0 {
t.Fatalf("expected users/data.json checksum and size to be populated: %#v", usersData) t.Fatalf("expected users/%s checksum and size to be populated: %#v", testDataFileName, usersData)
} }
} }
@@ -219,13 +284,32 @@ func mustMkdirAll(t *testing.T, path string) {
} }
} }
func mustWriteJSON(t *testing.T, path string, payload any) { func mustWriteStructured(t *testing.T, path string, payload any) {
t.Helper() t.Helper()
bytes, err := json.MarshalIndent(payload, "", " ") var (
if err != nil { bytes []byte
t.Fatalf("MarshalIndent(%q) error = %v", path, err) err error
} )
switch filepath.Ext(path) {
case ".cbor":
bytes, err = cbor.Marshal(payload)
case ".json":
bytes, err = json.MarshalIndent(payload, "", " ")
if err == nil {
bytes = append(bytes, '\n') bytes = append(bytes, '\n')
}
case ".jsonc":
bytes, err = json.MarshalIndent(payload, "", " ")
if err == nil {
bytes = append([]byte("// Fixture JSONC with comments and trailing commas enabled.\n"), bytes...)
bytes = append(bytes[:len(bytes)-2], []byte(",\n}\n")...)
}
default:
t.Fatalf("unsupported structured fixture file %q", path)
}
if err != nil {
t.Fatalf("marshal %q error = %v", path, err)
}
if err := os.WriteFile(path, bytes, 0o644); err != nil { if err := os.WriteFile(path, bytes, 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err) t.Fatalf("WriteFile(%q) error = %v", path, err)
} }
@@ -0,0 +1,58 @@
// Path: Backend/internal/posixproj/projector_types.go
package posixproj
import "moku-backend/internal/database"
const rootProjectionPath = "/"
type Projector struct {
db *database.DB
root string
}
type RebuildSummary struct {
TotalNodes int
DirectoryCount int
FileCount int
}
type NodeKind string
const (
NodeKindDirectory NodeKind = "directory"
NodeKindFile NodeKind = "file"
)
type Scope struct {
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
}
type Node struct {
Path string
ParentPath *string
Name string
Depth int
NodeKind NodeKind
LogicalType string
FileRole string
ResourceID string
ResourceName string
ResourceSlug string
InstallationID string
OrganizationID string
OrganizationSlug string
DepartmentSlug string
TeamSlug string
ProjectSlug string
PersonalSlug string
ContentJSON []byte
SizeBytes int64
Checksum string
}
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/worker/runner.go
package worker package worker
import ( import (
+2
View File
@@ -1,3 +1,5 @@
// Path: Backend/internal/worker/runner_test.go
package worker package worker
import ( import (
+12 -3
View File
@@ -1,30 +1,39 @@
project_root := justfile_directory() project_root := justfile_directory()
backend_dir := project_root + "/Backend" backend_dir := project_root + "/Backend"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Apply embedded database migrations. # Apply embedded database migrations.
migrate-up: migrate-up:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/migrate up cd '{{backend_dir}}' && go run ./cmd/migrate up
# Roll back the most recent embedded database migration. # Roll back the most recent embedded database migration (confirmation required).
migrate-down: migrate-down:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will roll back the most recent embedded database migration. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate down cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations. # Reset all embedded database migrations (confirmation required).
migrate-reset: migrate-reset:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate reset cd '{{backend_dir}}' && go run ./cmd/migrate reset
# Reset embedded database migrations and apply them again from scratch. # Reset embedded database migrations and apply them again from scratch (confirmation required).
migrate-rebuild: migrate-rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations and reapply them from scratch. Continue?"'
cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up
# Show the embedded database migration status. # Show the embedded database migration status.
migrate-status: migrate-status:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/migrate status cd '{{backend_dir}}' && go run ./cmd/migrate status
# Rebuild the POSIX-to-DB projection from the current POSIX root. # Rebuild the POSIX-to-DB projection from the current POSIX root.
posix-rebuild: posix-rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && go run ./cmd/posix rebuild cd '{{backend_dir}}' && go run ./cmd/posix rebuild
# Format backend Go source files. # Format backend Go source files.
fmt: fmt:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
+5 -1
View File
@@ -2,13 +2,17 @@ project_root := justfile_directory()
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
frontend_dir := project_root + "/Frontend" frontend_dir := project_root + "/Frontend"
node_modules_volume := "moku_work_frontend_node_modules" node_modules_volume := "moku_work_frontend_node_modules"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Recreate the frontend node_modules Docker volume. # Recreate the frontend node_modules Docker volume (confirmation required).
node_modules: node_modules:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will recreate the frontend node_modules Docker volume. Continue?"'
docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true
docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
# Run the frontend TypeScript check. # Run the frontend TypeScript check.
tsc: tsc:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{frontend_dir}}' && pnpm typecheck cd '{{frontend_dir}}' && pnpm typecheck
+2 -2
View File
@@ -18,7 +18,7 @@ start:
# Alias for the main full local development flow. # Alias for the main full local development flow.
dev: up dev: up
# Stop and remove the local development stack. # Stop and remove the local development stack (confirmation required).
down: down:
bash '{{stack_runner}}' down bash '{{stack_runner}}' down
@@ -34,6 +34,6 @@ logs:
restart: restart:
bash '{{stack_runner}}' restart bash '{{stack_runner}}' restart
# Stop the local development stack and remove local images, volumes, and backend dev state. # Stop the local development stack and remove local images, volumes, backend dev state, and the local POSIX folder (confirmation required).
clean: clean:
bash '{{stack_runner}}' clean bash '{{stack_runner}}' clean
+19 -5
View File
@@ -7,6 +7,7 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
backend_dir="$project_root/Backend" backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl" backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env" env_dir="$project_root/Env"
@@ -15,11 +16,14 @@ runtime_dir="$backend_dir/tmp/dev"
backend_image="moku/work-backend:dev" backend_image="moku/work-backend:dev"
backend_go_pkg_volume="moku_work_backend_go_pkg" backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build" backend_go_build_volume="moku_work_backend_go_build"
local_uid=$(id -u)
local_gid=$(id -g)
services=(web api worker) services=(web api worker)
source "$script_dir/docker.sh" source "$script_dir/docker.sh"
source "$script_dir/env.sh" source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_backend() { build_backend() {
cd "$backend_dir" cd "$backend_dir"
@@ -27,20 +31,20 @@ build_backend() {
} }
up_backend() { up_backend() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
} }
down_backend() { down_backend() {
docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true
docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true
} }
restart_backend() { restart_backend() {
docker compose -f "$compose_file" restart "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart "${services[@]}"
} }
follow_logs() { follow_logs() {
docker compose -f "$compose_file" logs -f "${services[@]}" LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f "${services[@]}"
} }
clean_runtime() { clean_runtime() {
@@ -50,34 +54,44 @@ clean_runtime() {
case "$action" in case "$action" in
check) check)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
;; ;;
build) build)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_backend build_backend
;; ;;
up) up)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
up_backend up_backend
run_compose_api_migrations "$compose_file" "api"
;; ;;
down) down)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will stop and remove the local backend dev containers. Continue?'
down_backend down_backend
;; ;;
restart) restart)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
restart_backend restart_backend
;; ;;
logs) logs)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs follow_logs
;; ;;
clean) clean)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will remove the local backend dev containers, images, volumes, and runtime state. Continue?'
down_backend down_backend
remove_docker_image_if_present "$backend_image" remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$backend_go_pkg_volume" remove_docker_volume_if_present "$backend_go_pkg_volume"
+19 -5
View File
@@ -7,12 +7,15 @@ action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd)
posix_root="$project_root/POSIX"
frontend_dir="$project_root/Frontend" frontend_dir="$project_root/Frontend"
frontend_bake="$frontend_dir/docker-bake.hcl" frontend_bake="$frontend_dir/docker-bake.hcl"
backend_dir="$project_root/Backend" backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl" backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env" env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml" compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
local_uid=$(id -u)
local_gid=$(id -g)
frontend_image="moku/work-frontend:dev" frontend_image="moku/work-frontend:dev"
backend_image="moku/work-backend:dev" backend_image="moku/work-backend:dev"
frontend_volume="moku_work_frontend_node_modules" frontend_volume="moku_work_frontend_node_modules"
@@ -22,6 +25,7 @@ backend_runtime_dir="$backend_dir/tmp/dev"
source "$script_dir/docker.sh" source "$script_dir/docker.sh"
source "$script_dir/env.sh" source "$script_dir/env.sh"
source "$project_root/Commands/Local/scripts/common.sh"
build_frontend() { build_frontend() {
cd "$frontend_dir" cd "$frontend_dir"
@@ -39,19 +43,19 @@ build_images() {
} }
up_stack() { up_stack() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
} }
down_stack() { down_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes
} }
follow_logs() { follow_logs() {
docker compose -f "$compose_file" logs -f LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f
} }
clean_stack() { clean_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
remove_docker_image_if_present "$frontend_image" remove_docker_image_if_present "$frontend_image"
remove_docker_image_if_present "$backend_image" remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$frontend_volume" remove_docker_volume_if_present "$frontend_volume"
@@ -63,37 +67,47 @@ clean_stack() {
start_stack() { start_stack() {
build_images build_images
up_stack up_stack
run_compose_api_migrations "$compose_file" "api"
} }
case "$action" in case "$action" in
build) build)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_posix_root "$project_root" "$posix_root"
build_images build_images
;; ;;
up|start|rebuild) up|start|rebuild)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
start_stack start_stack
;; ;;
down) down)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will stop the local development stack and remove its containers and volumes. Continue?'
down_stack down_stack
;; ;;
restart) restart)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
docker compose -f "$compose_file" restart ensure_posix_root "$project_root" "$posix_root"
LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart
;; ;;
logs) logs)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
follow_logs follow_logs
;; ;;
clean) clean)
ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir" ensure_local_env_file "$env_dir"
ensure_posix_root "$project_root" "$posix_root"
confirm_destructive_action 'This will remove the local development stack, local images, volumes, backend dev state, and the local POSIX folder. Continue?'
clean_stack clean_stack
rm -rf "$posix_root"
;; ;;
*) *)
printf 'Unsupported dev stack action: %s\n' "$action" >&2 printf 'Unsupported dev stack action: %s\n' "$action" >&2
+17 -8
View File
@@ -5,40 +5,49 @@ local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
proxy_image := "moku/work-proxy:local-prod" proxy_image := "moku/work-proxy:local-prod"
backend_api_image := "moku/work-backend:local-prod-api" backend_api_image := "moku/work-backend:local-prod-api"
backend_worker_image := "moku/work-backend:local-prod-worker" backend_worker_image := "moku/work-backend:local-prod-worker"
common_sh := project_root + "/Commands/Local/scripts/common.sh"
posix_root := project_root + "/POSIX"
# Build the local production proxy image locally. # Build the local production proxy image locally.
build: build:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker
# Start the local production stack in the background using the current image. # Start the local production stack in the background using the current image.
up: up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background. # Build first, then start the local production stack in the background.
start: build up start: build up
# Rebuild the local production proxy image locally. # Rebuild the local production proxy image locally.
rebuild: rebuild:
bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack. # Stop and remove the local production stack (confirmation required).
down: down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will stop the local production stack and remove its containers and volumes. Continue?"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack. # Follow logs for the local production stack.
logs: logs:
docker compose -f '{{local_compose}}' logs -f bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack. # Restart the local production stack.
restart: restart:
docker compose -f '{{local_compose}}' restart bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' restart
# Stop the local production stack and remove local images. # Stop the local production stack and remove local images (confirmation required).
clean: clean:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will remove the local production stack, its volumes, and the local production images. Continue?"'
LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true
docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_posix_root() {
local project_root=${1:?project root is required}
local posix_root=${2:-"$project_root/POSIX"}
mkdir -p "$posix_root"
}
confirm_destructive_action() {
local prompt=${1:-Are you sure you want to continue?}
local response_input=""
if [[ "${JUST_YES:-0}" == "1" ]]; then
return 0
fi
if [[ -t 0 ]]; then
printf '%s [y/N] ' "$prompt"
IFS= read -r response_input
elif [[ -r /dev/tty ]]; then
printf '%s [y/N] ' "$prompt" > /dev/tty
IFS= read -r response_input < /dev/tty
else
printf 'Refusing destructive action without interactive confirmation. Re-run with JUST_YES=1 to continue.\n' >&2
exit 1
fi
case "$response_input" in
y|Y|yes|YES|Yes)
return 0
;;
*)
printf 'Aborted.\n' >&2
exit 1
;;
esac
}
run_compose_api_migrations() {
local compose_file=${1:?compose file is required}
local service_name=${2:-api}
local max_attempts=${3:-20}
local sleep_seconds=${4:-2}
local attempt=1
local output=""
while (( attempt <= max_attempts )); do
if output=$(docker compose -f "$compose_file" exec -T "$service_name" sh -lc 'go run ./cmd/migrate up' 2>&1); then
printf '%s\n' "$output"
return 0
fi
if (( attempt == max_attempts )); then
printf 'Failed to apply database migrations automatically after %d attempts.\n' "$max_attempts" >&2
printf '%s\n' "$output" >&2
return 1
fi
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
+6 -2
View File
@@ -1,12 +1,16 @@
x-backend-service: &backend-service x-backend-service: &backend-service
image: moku/work-backend:dev image: moku/work-backend:dev
restart: unless-stopped restart: unless-stopped
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
env_file: env_file:
- ../Env/.env.local - ../Env/.env.local
environment: environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0 VALKEY_URL: redis://valkey:6379/0
POSIX_ROOT: /posix POSIX_ROOT: /posix
HOME: /tmp/home
GOMODCACHE: /tmp/go/pkg/mod
GOCACHE: /tmp/go-build
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -15,8 +19,8 @@ x-backend-service: &backend-service
volumes: volumes:
- ../Backend:/app - ../Backend:/app
- ../POSIX:/posix - ../POSIX:/posix
- moku_work_backend_go_pkg:/go/pkg/mod - moku_work_backend_go_pkg:/tmp/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build - moku_work_backend_go_build:/tmp/go-build
services: services:
postgres: postgres:
+1
View File
@@ -1,5 +1,6 @@
x-backend-service: &backend-service x-backend-service: &backend-service
restart: unless-stopped restart: unless-stopped
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
env_file: env_file:
- ../Env/.env.local - ../Env/.env.local
environment: environment:
+42 -36
View File
@@ -1,75 +1,81 @@
# POSIX Structure # POSIX Structure
[Filetree Link](https://tree.nathanfriend.com/?s=(%27optiUs!(%27fancy!Yue~fullPath!fbq~YailingSlash!Yue~rootDot!fbq)~R(%27R%27PJ%20or%20OrganizatiU%20%7Bqrver%7DM*46layout6cNlog7packs7*pack37A2*enYies75W5A_standbUe7WA6HwH30LZ2teamw5teamGL5Z6FwF30LKTC058T5C7XFG5LXKXTXC7XI7I05QG5BN2*8QG5BN6Z04_dN_pJw*pJGlayout2L*KI70%27)~vEiU!%271%27)*%20%200M52_*3-%3Cslug%3E4qttings5**69M*7%2F08VG*V259.jsUA5manifestBQ25*schema25*dCchildrenEersFprojectG305HdepartmentI*YeeJEUbKhome2L*42M%5CnNataQitemRsource!Tacl2UonVfolderW*app37X55YtrZusE_90balqsews0%01wqb_ZYXWVUTRQNMLKJIHGFECBA987654320*) Note: regenerate the external filetree link if you want the hosted visual tree to reflect the new `.cbor` and `.jsonc` paths.
``` markdown ``` markdown
Personal or Organization (server)/ Personal or Organization (server)/
├── settings.json ├── settings.cbor
├── layout.json
├── catalog/ ├── catalog/
│ ├── packs/ │ ├── packs/
│ │ └── pack-<slug>/ │ │ └── pack-<slug>/
│ │ ├── manifest.json │ │ ├── manifest.jsonc
│ │ └── entries/ │ │ └── entries/
│ │ └── app-<slug>/ │ │ └── app-<slug>/
│ │ └── manifest.json │ │ └── manifest.jsonc
│ └── standalone/ │ └── standalone/
│ └── app-<slug>/ │ └── app-<slug>/
│ └── manifest.json │ └── manifest.jsonc
├── departments/ ├── departments/
│ └── department-<slug>/ │ └── department-<slug>/
│ ├── settings.json │ ├── settings.cbor
│ ├── users.json │ ├── users.cbor
│ └── teams/ │ └── teams/
│ └── team-<slug>/ │ └── team-<slug>/
│ ├── settings.json │ ├── settings.cbor
│ └── users.json │ └── users.cbor
├── projects/ ├── projects/
│ └── project-<slug>/ │ └── project-<slug>/
│ ├── settings.json │ ├── settings.cbor
│ ├── home.json │ ├── home.cbor
│ ├── acl.json │ ├── acl.cbor
│ ├── children/ │ ├── children/
│ │ └── folder-<slug>/ │ │ └── folder-<slug>/
│ │ ├── folder.json │ │ ├── folder.cbor
│ │ ├── acl.json │ │ ├── acl.cbor
│ │ └── children/ │ │ └── children/
│ │ └── project-<slug>/ │ │ └── project-<slug>/
│ │ ├── settings.json │ │ ├── settings.cbor
│ │ ├── home.json │ │ ├── home.cbor
│ │ ├── acl.json │ │ ├── acl.cbor
│ │ ├── children/ │ │ ├── children/
│ │ └── tree/ │ │ └── tree/
│ └── tree/ │ └── tree/
│ ├── item-<slug>/ │ ├── item-<slug>/
│ │ ├── item.json │ │ ├── item.cbor
│ │ ├── schema.json │ │ ├── schema.json
│ │ └── data.json │ │ └── data.cbor
│ └── folder-<slug>/ │ └── folder-<slug>/
│ ├── folder.json │ ├── folder.cbor
│ └── item-<slug>/ │ └── item-<slug>/
│ ├── item.json │ ├── item.cbor
│ ├── schema.json │ ├── schema.json
│ └── data.json │ └── data.cbor
└── users/ └── users/
├── settings.json ├── settings.cbor
├── data.json ├── data.cbor
└── personals/ └── personals/
└── personal-<slug>/ └── personal-<slug>/
├── layout.json ├── layout.cbor
├── settings.json ├── settings.cbor
├── home.json ├── home.cbor
└── tree/ └── tree/
``` ```
## File Responsibilities ## File Responsibilities
- `settings.json` — Metadata and presentation config for the thing, such as display name, icon, description, and simple settings. - `settings.cbor` — Machine-owned metadata and presentation config for the thing, such as display name, icon, description, and simple settings.
- `layout.json` — Layout configuration for the current server or personal space. - `layout.cbor` — Machine-owned layout configuration for the current server or personal space.
- `home.json` — Home surface configuration, such as widgets, sections, and how they are arranged. - `home.cbor` — Machine-owned home surface configuration, such as widgets, sections, and how they are arranged.
- `folder.json` — Metadata for a folder node in a tree. - `folder.cbor` — Machine-owned metadata for a folder node in a tree.
- `item.json` — Instance metadata for a created item, including what it is and how it should behave. - `item.cbor` — Machine-owned instance metadata for a created item, including what it is and how it should behave.
- `schema.json` — The structure expected by that item's data. - `schema.json` — The structure expected by that item's data.
- `data.json` — The actual content or state data for that item. - `data.cbor` — The machine-owned content or state data for that item.
- `manifest.json` — Catalog definition metadata, including versioning, description, and capabilities for reusable apps or entries. - `manifest.jsonc` — Human-facing catalog definition metadata, including versioning, description, capabilities, and comments for reusable apps or entries.
- `users.json` — User membership or assignment data for departments and teams. - `users.cbor` — Machine-owned user membership or assignment data for departments and teams.
- `acl.cbor` — Machine-owned access control data for projects and folders.
## Format Rules
- Use `*.jsonc` for catalog manifests that may be authored or reviewed by humans.
- Use `*.cbor` for machine-owned POSIX state and metadata files where parse speed, compactness, and backupability matter more than direct editability.
- Keep `schema.json` as JSON so it remains compatible with JSON Schema tooling and validation flows.
+3 -3
View File
@@ -63,11 +63,11 @@
- [ ] POSIX-Lite-File-Persistence-Foundation - [ ] POSIX-Lite-File-Persistence-Foundation
- [ ] Mounted-Storage-Root-Config - [ ] Mounted-Storage-Root-Config
- [ ] Project-Folder-Creation-On-Backend - [ ] Project-Folder-Creation-On-Backend
- [ ] moku.project.json - [ ] manifest.jsonc
- [ ] Item-Folder-Creation - [ ] Item-Folder-Creation
- [ ] item.json - [ ] item.cbor
- [ ] schema.json - [ ] schema.json
- [ ] data.json - [ ] data.cbor
- [ ] DB-To-Files-Write-Flow - [ ] DB-To-Files-Write-Flow
- [ ] User-and-Workspace-Domain-Readiness - [ ] User-and-Workspace-Domain-Readiness
- [ ] Base-Workspace - [ ] Base-Workspace
+1 -1
View File
@@ -1,7 +1,7 @@
// Path: Frontend/src/app.tsx // Path: Frontend/src/app.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell"; import { AppShell } from "./components/app-shell/AppShell/AppShell";
import "./styles/main.scss"; import "./styles/main.scss";
import "./styles/user-overrides.scss"; import "./styles/user-overrides.scss";
@@ -1,17 +1,19 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx // Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js"; import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime"; import { getDocumentTheme, setTheme, type Theme } from "../../../helper/themeRuntime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome"; import { BootstrapWizard } from "../../bootstrap/BootstrapWizard/BootstrapWizard";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome";
import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context"; import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail"; import { LeftRail } from "../../workspace-navigation/LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav"; import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser"; import { MobileWorkspaceBrowser } from "../../workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock"; import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../TopBar/NotificationsMenu"; import { NotificationsMenu } from "../../top-bar/TopBar/NotificationsMenu";
import { ProfileMenu } from "../TopBar/ProfileMenu"; import { ProfileMenu } from "../../top-bar/TopBar/ProfileMenu";
import { TopBar } from "../TopBar/TopBar"; import { TopBar } from "../../top-bar/TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar"; import { WorkspaceTopBar } from "../../workspace-navigation/WorkspaceTopBar/WorkspaceTopBar";
import { WorkspaceSidebar } from "../../workspace-navigation/WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss"; import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null; type MobileWorkspaceView = "notifications" | "profile" | null;
@@ -80,6 +82,8 @@ const AppShellContent = (): JSX.Element => {
setActiveMobileWorkspaceView(null); setActiveMobileWorkspaceView(null);
}; };
const workspaceBreadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
return ( return (
<div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}> <div class={styles.shell} data-ui="app-shell" data-app-shell-status={appShellData.status()}>
<TopBar <TopBar
@@ -124,12 +128,16 @@ const AppShellContent = (): JSX.Element => {
<Show <Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null} when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={ fallback={
<WorkspaceHome <>
<WorkspaceTopBar
sidebarCollapsed={isSidebarCollapsed()} sidebarCollapsed={isSidebarCollapsed()}
breadcrumb={workspaceBreadcrumb()}
onToggleSidebarCollapse={(): void => { onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed); setIsSidebarCollapsed((collapsed) => !collapsed);
}} }}
/> />
<WorkspaceHome />
</>
} }
> >
<div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}> <div class={styles.mobileWorkspaceView} data-slot="mobile-workspace-view" data-view={activeMobileWorkspaceView() ?? undefined}>
@@ -162,6 +170,7 @@ const AppShellContent = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false); setIsMobileWorkspaceBrowserOpen(false);
}} }}
/> />
<BootstrapWizard />
</div> </div>
); );
}; };
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx // Path: Frontend/src/components/app-shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../data/app-shell.context";
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx // Path: Frontend/src/components/app-shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../data/app-shell.context";
@@ -0,0 +1,169 @@
// Path: Frontend/src/components/app-shell/data/app-shell.builders.ts
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
import type { AppShellPayload } from "./app-shell.types";
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
export const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
export const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
export const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
active: index === 0,
}));
};
export const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
export const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams.filter((team) => team.departmentId === department.id).map((team) => team.name),
active: index === 0,
}));
};
export const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
export const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
return [];
};
export const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
@@ -0,0 +1,97 @@
// Path: Frontend/src/components/app-shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type JSX,
} from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import {
buildActiveDepartment,
buildActiveProject,
buildActiveServer,
buildActiveUserProfile,
buildDepartmentItems,
buildProjectItems,
buildRailItems,
buildWorkspaceTree,
} from "./app-shell.builders";
import type { AppShellContextValue, AppShellPayload } from "./app-shell.types";
import { normalizeAppShellPayload } from "./app-shell.types";
const AppShellContext = createContext<AppShellContextValue>();
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as {
data?: AppShellPayload;
error?: { message?: string } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};
@@ -0,0 +1,130 @@
// Path: Frontend/src/components/app-shell/data/app-shell.types.ts
import type { Accessor } from "solid-js";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
ProjectItem,
RailItem,
WorkspaceTreeNode,
} from "./shell.data";
export type AppShellInstallation = {
id: string;
name: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
export type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
export type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
export type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
export type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
export type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
export type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
export type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
export type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
export const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
export const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
@@ -0,0 +1,174 @@
// Path: Frontend/src/components/app-shell/data/shell.context-menu.ts
import { getWorkspaceCreateActions, getWorkspaceItemTypeDefinition } from "./shell.registry";
import type {
ActiveProject,
ProjectContextMenuAction,
ProjectContextMenuSection,
ProjectItem,
ProjectMenuTarget,
WorkspaceContextMenuSection,
WorkspaceContextMenuTarget,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export { getWorkspaceContextMenuEyebrow } from "./shell.registry";
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
...(node.kind === "folder"
? { kind: "folder" as const }
: {
kind: "item" as const,
itemType: node.itemType,
}),
});
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = getWorkspaceCreateActions();
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{ id: "create", label: undefined, items: [createSubmenuAction] },
{ id: "workspace", label: undefined, items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }] },
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{ id: "open", items: [{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } }, { id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } }] },
{ id: "create", label: undefined, items: [createSubmenuAction] },
{
id: "organize",
label: undefined,
items: [
{ id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "item": {
const definition = getWorkspaceItemTypeDefinition(target.itemType);
const actionPrefix = definition.actionPrefix;
const nounLabel = definition.noun;
return [
{
id: `${actionPrefix}-primary`,
items: [
{ id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
{ id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
}
};
const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "new-project", label: "New project" },
{ id: "new-folder", label: "New folder" },
] as const;
const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
] as const;
export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface",
label,
kind: "surface",
});
export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
id,
label,
kind: "folder",
});
export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
id: project.id,
label: project.name,
kind: "project",
});
export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
switch (target.kind) {
case "surface":
return "Projects";
case "folder":
return "Folder";
case "project":
return "Project";
}
};
export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
const createActions = getProjectCreateActions();
switch (target.kind) {
case "surface":
return [{ id: "create", items: createActions }] as const;
case "folder":
return [
{ id: "create", items: createActions },
{ id: "organize", items: getProjectFolderDangerActions() },
] as const;
case "project":
return [{ id: "create", items: createActions }] as const;
}
};
@@ -0,0 +1,6 @@
// Path: Frontend/src/components/app-shell/data/shell.data.ts
export * from "./shell.types";
export * from "./shell.registry";
export * from "./shell.scaffold";
export * from "./shell.context-menu";
@@ -0,0 +1,100 @@
// Path: Frontend/src/components/app-shell/data/shell.registry.ts
import { FileText, LayoutGrid } from "../../../lib/icons";
import type {
ShellIcon,
WorkspaceContextMenuAction,
WorkspaceContextMenuTarget,
WorkspaceItemTypeDefinition,
WorkspaceItemTypeId,
WorkspaceTreeNode,
} from "./shell.types";
export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
{
id: "core.doc",
label: "Doc",
shortLabel: "Doc",
icon: FileText,
noun: "doc",
actionPrefix: "doc",
defaultCreateLabel: "New doc",
includeInWorkspaceCreate: true,
description: "Rich text documents and notes.",
},
{
id: "core.board.kanban",
label: "Kanban board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "board",
defaultCreateLabel: "New board",
includeInWorkspaceCreate: true,
description: "Default board-style workspace item.",
},
{
id: "core.board.list",
label: "List board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "list-board",
defaultCreateLabel: "New list board",
description: "Alternate first-party board view prepared for the future registry.",
},
] as const;
const workspaceItemTypeMap = new Map<WorkspaceItemTypeId, WorkspaceItemTypeDefinition>(
firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
);
const createUnknownWorkspaceItemTypeDefinition = (
itemType: WorkspaceItemTypeId,
): WorkspaceItemTypeDefinition => ({
id: itemType,
label: "Item",
shortLabel: "Item",
icon: FileText,
noun: "item",
actionPrefix: "item",
defaultCreateLabel: "New item",
description: "Fallback definition for unknown or future workspace item types.",
});
export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
};
export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
export const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
...firstPartyWorkspaceItemTypes
.filter((definition) => definition.includeInWorkspaceCreate)
.map((definition) => ({
id: `create-${definition.actionPrefix}`,
label: definition.defaultCreateLabel,
shortcut:
definition.id === "core.board.kanban"
? ({ modifiers: ["alt"], key: "b" } as const)
: definition.id === "core.doc"
? ({ modifiers: ["alt"], key: "d" } as const)
: undefined,
})),
];
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "item":
return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
}
};
@@ -0,0 +1,222 @@
// Path: Frontend/src/components/app-shell/data/shell.scaffold.ts
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
ListCollapse,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
import type {
ActiveDepartment,
ActiveProject,
ActiveServer,
ActiveUserProfile,
DepartmentItem,
MobileBottomNavItem,
NotificationItem,
ProfileMenuSection,
ProjectItem,
RailItem,
ServerDockAction,
SidebarHeaderAction,
TopBarAction,
WorkspaceStaticItem,
WorkspaceTreeNode,
} from "./shell.types";
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{
id: "general",
name: "General",
description: "Default shared project",
groupLabel: "Shared space",
parentLabel: "Workspace home",
meta: "1 workspace",
active: true,
},
{
id: "operations",
name: "Operations",
description: "Cross-team planning and delivery",
groupLabel: "Team folders",
parentLabel: "Shared Services",
meta: "2 workspaces",
},
{
id: "hiring",
name: "Hiring",
description: "Candidate pipeline and interview loops",
groupLabel: "Team folders",
parentLabel: "People Ops",
meta: "1 workspace",
},
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
{ id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
{ id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
],
},
{ id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
export const topBarActions: readonly TopBarAction[] = [{ id: "search", label: "Search", icon: Search }] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -0,0 +1,233 @@
// Path: Frontend/src/components/app-shell/data/shell.types.ts
import type { Component } from "solid-js";
export type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
groupLabel?: string;
parentLabel?: string;
meta?: string;
active?: boolean;
};
export type ProjectMenuTarget =
| {
id: string;
label: string;
kind: "surface";
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "project";
};
export type ProjectContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly ProjectContextMenuAction[];
};
export type ProjectContextMenuSection = {
id: string;
label?: string;
items: readonly ProjectContextMenuAction[];
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: string;
path?: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
active?: boolean;
meta?: string;
children?: undefined;
};
export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
export type WorkspaceItemTypeDefinition = {
id: WorkspaceItemTypeId;
label: string;
shortLabel: string;
icon: ShellIcon;
noun: string;
actionPrefix: string;
defaultCreateLabel: string;
includeInWorkspaceCreate?: boolean;
description?: string;
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget =
| {
id: string;
label: string;
kind: WorkspaceStaticKind;
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.data.ts // Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure"; export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
@@ -1,8 +1,7 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.hook.ts // Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
import { import {
bootstrapPersonaDefinitions, bootstrapPersonaDefinitions,
bootstrapStepDefinitions, bootstrapStepDefinitions,
@@ -20,7 +19,8 @@ import {
type InstanceForm, type InstanceForm,
type ModeForm, type ModeForm,
type StructureForm, type StructureForm,
} from "./WorkspaceHome.data"; } from "./BootstrapWizard.data";
import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = { type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined; installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
@@ -49,67 +49,7 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({
const materializationPollIntervalMs = 2000; const materializationPollIntervalMs = 2000;
const readResponseBody = async (response: Response): Promise<unknown> => { export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm }); const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm }); const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm }); const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
@@ -303,7 +243,6 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
}); });
}); });
const apiBase = (): string => resolveAPIBase();
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder; const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length); const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!); const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
@@ -342,19 +281,7 @@ export const useWorkspaceHomeWizard = (appShellData: AppShellBootstrapAdapter) =
setStepState(step, { status: "submitting", error: "" }); setStepState(step, { status: "submitting", error: "" });
try { try {
const response = await fetch(`${apiBase()}/bootstrap/steps/${step}`, { await submitBootstrapStepRequest(step, payload);
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(readResponseError(step, data));
}
setStepState(step, { setStepState(step, {
status: "success", status: "success",
@@ -1,4 +1,4 @@
/* Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss */ /* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport, .viewport,
.wizardLayer { .wizardLayer {
@@ -1,3 +1,5 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons"; import { CircleHelp } from "../../../lib/icons";
import { import {
@@ -11,8 +13,8 @@ import {
type InstanceForm, type InstanceForm,
type ModeForm, type ModeForm,
type StructureForm, type StructureForm,
} from "./WorkspaceHome.data"; } from "./BootstrapWizard.data";
import styles from "./WorkspaceHome.module.scss"; import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = { type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error"; status: "idle" | "submitting" | "success" | "error";
@@ -0,0 +1,107 @@
import { type JSX } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { useBootstrapWizard } from "./BootstrapWizard.hook";
import { BootstrapWizardDialog } from "./BootstrapWizardDialog";
export const BootstrapWizard = (): JSX.Element => {
const appShellData = useAppShellData();
const {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isWizardOpen,
setIsWizardOpen,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
handleCurrentStepSubmit,
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack,
navigateToVisibleStep,
} = useBootstrapWizard(appShellData);
return (
<BootstrapWizardDialog
isOpen={isBootstrapStateResolved() && isWizardOpen()}
currentStep={currentStep()}
currentStepState={currentStepState()}
activeWizardSteps={activeWizardSteps()}
currentWizardStepIndex={currentWizardStepIndex()}
stepState={stepState}
bootstrapStepCount={bootstrapStepCount()}
wizardProgressFillWidth={wizardProgressFillWidth()}
showBootstrapFinishingState={showBootstrapFinishingState()}
materializationState={materializationState()}
materializationStatusLabel={materializationStatusLabel()}
materializationMessage={materializationMessage()}
isMaterializationInFlight={isMaterializationInFlight()}
hasMaterializationFailed={hasMaterializationFailed()}
instanceForm={instanceForm}
modeForm={modeForm}
adminForm={adminForm}
structureForm={structureForm}
selectedPersona={selectedPersona()}
hasChosenPersona={hasChosenPersona()}
selectedPersonaIsAvailable={selectedPersonaIsAvailable()}
usesCondensedBootstrapFlow={usesCondensedBootstrapFlow()}
bootstrapNamePlaceholder={bootstrapNamePlaceholder()}
isFirstStep={isFirstStep()}
canDismissWizard={canDismissWizard()}
fieldTooltip={fieldTooltip()}
onClose={(): void => {
setIsWizardOpen(false);
}}
onCloseFinishingState={(): void => {
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
}}
onSubmit={handleCurrentStepSubmit}
onSelectPersona={applyPersonaSelection}
onProtocolChange={(value): void => setInstanceForm("protocol", value)}
onAccessChange={(value): void => setInstanceForm("access", value)}
onHostChange={(value): void => setInstanceForm("host", value)}
onModeNameChange={(value): void => setModeForm("name", value)}
onProjectNameChange={(value): void => setStructureForm("projectName", value)}
onTeamNameChange={(value): void => setStructureForm("teamName", value)}
onAdminDisplayNameChange={(value): void => setAdminForm("displayName", value)}
onAdminEmailChange={(value): void => setAdminForm("email", value)}
onAdminPasswordChange={(value): void => setAdminForm("password", value)}
onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
statusLabel={statusLabel}
stepStatusLabel={stepStatusLabel}
onNavigateBack={navigateBack}
onSelectStep={navigateToVisibleStep}
/>
);
};
@@ -0,0 +1,234 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizardDialog.tsx
import { Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import {
type AdminForm,
type BootstrapPersona,
bootstrapPersonaDefinitions,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { type BootstrapSubmissionState, type FieldTooltipState, type MaterializationState } from "./BootstrapWizard.hook";
import {
BootstrapAdminStep,
BootstrapFinishingState,
BootstrapInstanceStep,
BootstrapModeStep,
BootstrapPersonaStep,
BootstrapStructureStep,
BootstrapWizardProgress,
} from "./BootstrapWizard.parts";
import styles from "./BootstrapWizard.module.scss";
export type BootstrapWizardDialogProps = {
isOpen: boolean;
currentStep: BootstrapStepDefinition;
currentStepState: BootstrapSubmissionState;
activeWizardSteps: readonly BootstrapStepDefinition[];
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
showBootstrapFinishingState: boolean;
materializationState: MaterializationState;
materializationStatusLabel: string;
materializationMessage: string;
isMaterializationInFlight: boolean;
hasMaterializationFailed: boolean;
instanceForm: InstanceForm;
modeForm: ModeForm;
adminForm: AdminForm;
structureForm: StructureForm;
selectedPersona: BootstrapPersona;
hasChosenPersona: boolean;
selectedPersonaIsAvailable: boolean;
usesCondensedBootstrapFlow: boolean;
bootstrapNamePlaceholder: string;
isFirstStep: boolean;
canDismissWizard: boolean;
fieldTooltip: FieldTooltipState | null;
onClose: () => void;
onCloseFinishingState: () => void;
onSubmit: JSX.EventHandlerUnion<HTMLFormElement, SubmitEvent>;
onSelectPersona: (persona: BootstrapPersona) => void;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
onModeNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onAdminDisplayNameChange: (value: string) => void;
onAdminEmailChange: (value: string) => void;
onAdminPasswordChange: (value: string) => void;
onDepartmentNameChange: (value: string) => void;
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
statusLabel: (state: BootstrapSubmissionState) => string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onNavigateBack: () => void;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardDialog = (props: BootstrapWizardDialogProps): JSX.Element => (
<Show when={props.isOpen}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={props.currentStep.id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap Server
</h2>
</div>
<Show when={props.canDismissWizard}>
<button type="button" class={styles.wizardCloseButton} onClick={props.onClose}>
Close
</button>
</Show>
</header>
<Show
when={!props.showBootstrapFinishingState}
fallback={
<BootstrapFinishingState
materializationState={props.materializationState}
statusLabel={props.materializationStatusLabel}
message={props.materializationMessage}
isInFlight={props.isMaterializationInFlight}
hasFailed={props.hasMaterializationFailed}
onClose={props.onCloseFinishingState}
/>
}
>
<div class={styles.wizardBody}>
<Show when={props.currentStep.id !== "persona"}>
<BootstrapWizardProgress
steps={props.activeWizardSteps}
currentStepId={props.currentStep.id}
currentWizardStepIndex={props.currentWizardStepIndex}
stepState={props.stepState}
bootstrapStepCount={props.bootstrapStepCount}
wizardProgressFillWidth={props.wizardProgressFillWidth}
stepStatusLabel={props.stepStatusLabel}
onSelectStep={props.onSelectStep}
/>
</Show>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<Show when={props.currentStep.id !== "persona" || props.statusLabel(props.currentStepState)}>
<div class={styles.sectionHeader}>
<Show when={props.currentStep.id !== "persona"}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${props.currentWizardStepIndex + 1} of ${props.bootstrapStepCount}`}</span>
</div>
</Show>
<Show when={props.statusLabel(props.currentStepState)}>
<div class={styles.statusBadge} data-status={props.currentStepState.status}>
{props.statusLabel(props.currentStepState)}
</div>
</Show>
</div>
</Show>
<form class={styles.form} onSubmit={props.onSubmit}>
<Show when={props.currentStep.id === "persona"}>
<BootstrapPersonaStep
personas={bootstrapPersonaDefinitions}
hasChosenPersona={props.hasChosenPersona}
selectedPersona={props.selectedPersona}
selectedPersonaIsAvailable={props.selectedPersonaIsAvailable}
onSelectPersona={props.onSelectPersona}
/>
</Show>
<Show when={props.currentStep.id === "instance"}>
<BootstrapInstanceStep
instanceForm={props.instanceForm}
onProtocolChange={props.onProtocolChange}
onAccessChange={props.onAccessChange}
onHostChange={props.onHostChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "mode"}>
<BootstrapModeStep
modeForm={props.modeForm}
structureForm={props.structureForm}
usesCondensedBootstrapFlow={props.usesCondensedBootstrapFlow}
selectedPersona={props.selectedPersona}
namePlaceholder={props.bootstrapNamePlaceholder}
onNameChange={props.onModeNameChange}
onProjectNameChange={props.onProjectNameChange}
onTeamNameChange={props.onTeamNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id === "admin"}>
<BootstrapAdminStep
adminForm={props.adminForm}
onDisplayNameChange={props.onAdminDisplayNameChange}
onEmailChange={props.onAdminEmailChange}
onPasswordChange={props.onAdminPasswordChange}
/>
</Show>
<Show when={props.currentStep.id === "structure"}>
<BootstrapStructureStep
mode={props.modeForm.mode}
structureForm={props.structureForm}
onDepartmentNameChange={props.onDepartmentNameChange}
onTeamNameChange={props.onTeamNameChange}
onProjectNameChange={props.onProjectNameChange}
onShowTooltip={props.onShowTooltip}
onHideTooltip={props.onHideTooltip}
/>
</Show>
<Show when={props.currentStep.id !== "persona"}>
<div class={styles.wizardFormActions}>
<button type="button" class={styles.secondaryButton} disabled={props.isFirstStep} onClick={props.onNavigateBack}>
Back
</button>
<button type="submit" class={styles.primaryButton} disabled={props.currentStepState.status === "submitting"}>
{props.currentStep.buttonLabel}
</button>
</div>
</Show>
</form>
<Show when={props.currentStepState.error}>
<p class={styles.errorText}>{props.currentStepState.error}</p>
</Show>
</div>
</div>
</Show>
</section>
<Show when={props.fieldTooltip}>
{(tooltip): JSX.Element => (
<div
class={styles.fieldTooltip}
data-placement={tooltip().placement}
style={{
left: `${tooltip().left}px`,
top: `${tooltip().top}px`,
}}
>
<div class={styles.fieldTooltipBubble}>{tooltip().text}</div>
</div>
)}
</Show>
</div>
</Portal>
</Show>
);
@@ -0,0 +1,81 @@
import { resolveAPIBase } from "../../../lib/api";
import type { BootstrapStepKey } from "./BootstrapWizard.data";
const readBootstrapResponseBody = async (response: Response): Promise<unknown> => {
const raw = await response.text();
if (!raw.trim()) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return raw;
}
};
const readBootstrapResponseError = (step: BootstrapStepKey, data: unknown): string => {
const fallback = `Bootstrap ${step} request failed.`;
if (typeof data === "string") {
const message = data.trim();
return message || fallback;
}
if (!data || typeof data !== "object") {
return fallback;
}
const record = data as {
error?: string;
message?: string;
requestId?: string;
};
const message = typeof record.message === "string" ? record.message.trim() : "";
const errorCode = typeof record.error === "string" ? record.error.trim() : "";
const requestId = typeof record.requestId === "string" ? record.requestId.trim() : "";
if (!message && !errorCode && !requestId) {
return fallback;
}
const details: string[] = [];
if (errorCode) {
details.push(`code: ${errorCode}`);
}
if (requestId) {
details.push(`request: ${requestId}`);
}
if (message && details.length > 0) {
return `${message} (${details.join(", ")})`;
}
if (message) {
return message;
}
return `${fallback} (${details.join(", ")})`;
};
export const submitBootstrapStepRequest = async (
step: BootstrapStepKey,
payload: unknown,
): Promise<void> => {
const response = await fetch(`${resolveAPIBase()}/bootstrap/steps/${step}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const data = await readBootstrapResponseBody(response);
if (!response.ok) {
throw new Error(readBootstrapResponseError(step, data));
}
};
@@ -1,638 +0,0 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { createLongPressGesture } from "../createLongPressGesture";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceItemTypeDefinition,
getWorkspaceNodeIcon,
workspaceStaticItems,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceItemTypeId,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import styles from "./MobileWorkspaceBrowser.module.scss";
type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
type MobileWorkspaceDialogState =
| {
kind: "text";
title: string;
message: string;
confirmLabel: string;
initialValue: string;
onConfirm: (value: string) => void;
}
| {
kind: "confirm";
title: string;
message: string;
confirmLabel: string;
tone?: "danger";
onConfirm: () => void;
};
type MobileMoveTargetState = {
kind: "folder" | "item";
id: string;
label: string;
};
type MobileMoveDestination = {
id: string | null;
label: string;
depth: number;
meta?: string;
};
const collectMoveDestinations = (
nodes: readonly WorkspaceTreeNode[],
movingTarget: MobileMoveTargetState,
depth = 0,
ancestorBlocked = false,
): MobileMoveDestination[] => {
const destinations: MobileMoveDestination[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
if (!ancestorBlocked && !isBlockedFolder) {
destinations.push({
id: node.id,
label: node.label,
depth,
meta: "Folder",
});
}
destinations.push(
...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
);
}
return destinations;
};
const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.kind !== "folder") {
continue;
}
const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
if (nestedMatch) {
return nestedMatch;
}
}
return null;
};
const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = getWorkspaceNodeIcon(props.node);
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
data-slot="mobile-workspace-tree-row"
data-kind={props.node.kind}
data-item-type={props.node.kind === "item" ? props.node.itemType : undefined}
data-active={props.node.active ? "true" : "false"}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }} data-slot="mobile-workspace-static-row" data-active={props.item.active ? "true" : "false"}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-static-item"
data-target-kind={target.kind}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const appShellData = useAppShellData();
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const [dialogState, setDialogState] = createSignal<MobileWorkspaceDialogState | null>(null);
const [dialogValue, setDialogValue] = createSignal("");
const [moveTarget, setMoveTarget] = createSignal<MobileMoveTargetState | null>(null);
const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
activeProjectId: () => appShellData.activeProject().id,
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
});
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const moveDestinations = () => {
const target = moveTarget();
if (!target) {
return [] as MobileMoveDestination[];
}
return [
{ id: null, label: "Items root", depth: 0, meta: "Root" },
...collectMoveDestinations(workspaceTreeNodes(), target),
];
};
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
switch (actionId) {
case "create-doc":
return "core.doc";
case "create-board":
return "core.board.kanban";
case "create-list-board":
return "core.board.list";
default:
return null;
}
};
const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
const definition = getWorkspaceItemTypeDefinition(itemType);
void createItem(definition.defaultCreateLabel, itemType, parentId);
};
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const closeMoveSheet = (): void => {
setMoveTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget());
};
const closeDialog = (): void => {
setDialogState(null);
setDialogValue("");
};
const openMoveSheet = (target: MobileMoveTargetState): void => {
setMoveTarget(target);
};
const openTextDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "text" }>, "kind">): void => {
setDialogValue(config.initialValue);
setDialogState({ kind: "text", ...config });
};
const openConfirmDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "confirm" }>, "kind">): void => {
setDialogValue("");
setDialogState({ kind: "confirm", ...config });
};
const submitDialog = (): void => {
const state = dialogState();
if (!state) {
return;
}
if (state.kind === "text") {
const value = dialogValue().trim();
if (!value) {
return;
}
closeDialog();
state.onConfirm(value);
return;
}
closeDialog();
state.onConfirm();
};
const handleMoveDestinationSelect = (destinationId: string | null): void => {
const target = moveTarget();
if (!target) {
return;
}
const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
const targetIndex = destinationNode?.kind === "folder"
? destinationNode.children?.length ?? 0
: destinationId
? 0
: workspaceTreeNodes().length;
closeMoveSheet();
if (target.kind === "folder") {
void moveFolder(target.id, destinationId, targetIndex);
return;
}
void moveItem(target.id, destinationId, targetIndex);
};
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
const createItemType = resolveCreateItemType(action.id);
if (createItemType) {
switch (target.kind) {
case "workspace":
case "home":
createPersistedItem(createItemType, null);
return;
case "folder":
createPersistedItem(createItemType, target.id);
return;
case "settings":
case "item":
return;
}
}
switch (action.id) {
case "new-folder": {
if (target.kind === "settings" || target.kind === "item") {
return;
}
openTextDialog({
title: "New folder",
message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
confirmLabel: "Create",
initialValue: "Untitled folder",
onConfirm: (name) => {
void createFolder(name, target.kind === "folder" ? target.id : null);
},
});
return;
}
case "rename-folder": {
if (target.kind !== "folder") {
return;
}
openTextDialog({
title: "Rename folder",
message: `Update the name for "${target.label}".`,
confirmLabel: "Save",
initialValue: target.label,
onConfirm: (name) => {
if (name === target.label) {
return;
}
void renameFolder(target.id, name);
},
});
return;
}
case "move-folder": {
if (target.kind !== "folder") {
return;
}
openMoveSheet({
kind: "folder",
id: target.id,
label: target.label,
});
return;
}
case "delete-folder": {
if (target.kind !== "folder") {
return;
}
openConfirmDialog({
title: "Delete folder?",
message: `"${target.label}" and everything inside it will be removed.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteFolder(target.id);
},
});
return;
}
case "move-doc":
case "move-board":
case "move-list-board": {
if (target.kind !== "item") {
return;
}
openMoveSheet({
kind: "item",
id: target.id,
label: target.label,
});
return;
}
case "delete-doc":
case "delete-board":
case "delete-list-board": {
if (target.kind !== "item") {
return;
}
openConfirmDialog({
title: "Delete item?",
message: `"${target.label}" will be removed from the project tree.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteItem(target.id);
},
});
return;
}
default:
return;
}
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return (
<Show when={props.open}>
<div class={styles.browserLayer} data-ui="mobile-workspace-browser">
<section class={styles.sheet} aria-label="Mobile workspace browser" data-slot="mobile-workspace-sheet">
<header class={styles.sheetHeader} data-slot="mobile-workspace-header">
<div
class={styles.brandBlock}
data-slot="mobile-workspace-brand"
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
</div>
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
<button
class={styles.createButton}
type="button"
aria-label="Create"
data-slot="mobile-workspace-create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" data-slot="mobile-workspace-close" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody} data-slot="mobile-workspace-body">
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
<WorkspaceTreeBranch nodes={workspaceTreeNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</div>
</section>
<WorkspaceMobileActionSheet target={actionSheetTarget()} onClose={closeActionSheet} onSelect={handleActionSelect} />
<Show when={moveTarget()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.moveSheetLayer} data-ui="mobile-workspace-move-sheet">
<button class={styles.dialogBackdrop} type="button" aria-label="Close move sheet" onClick={closeMoveSheet} />
<section class={styles.moveSheet} aria-label={`Move ${state().label}`}>
<div class={styles.moveSheetHandle} aria-hidden="true" />
<div class={styles.moveSheetHeader}>
<div class={styles.moveSheetHeaderCopy}>
<span class={styles.moveSheetEyebrow}>Move {state().kind}</span>
<strong class={styles.moveSheetTitle}>{state().label}</strong>
<p class={styles.moveSheetMessage}>Choose a new location in the project tree.</p>
</div>
</div>
<div class={styles.moveSection}>
<span class={styles.moveSectionLabel}>Destination</span>
<div class={styles.moveDestinationList}>
<For each={moveDestinations()}>
{(destination): JSX.Element => (
<button
type="button"
class={styles.moveDestinationButton}
style={{ "--move-depth": `${destination.depth}` }}
onClick={(): void => handleMoveDestinationSelect(destination.id)}
>
<span class={styles.moveDestinationLabel}>{destination.label}</span>
<Show when={destination.meta}>
<span class={styles.moveDestinationMeta}>{destination.meta}</span>
</Show>
</button>
)}
</For>
</div>
</div>
<div class={styles.moveSheetFooter}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeMoveSheet}>
Cancel
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
<Show when={dialogState()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.dialogLayer} data-ui="mobile-workspace-dialog">
<button class={styles.dialogBackdrop} type="button" aria-label="Close dialog" onClick={closeDialog} />
<section class={styles.dialogCard} aria-label={state().title}>
<div class={styles.dialogCopy}>
<strong class={styles.dialogTitle}>{state().title}</strong>
<p class={styles.dialogMessage}>{state().message}</p>
</div>
<Show when={state().kind === "text"}>
<input
class={styles.dialogInput}
type="text"
value={dialogValue()}
onInput={(event): void => {
setDialogValue(event.currentTarget.value);
}}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
submitDialog();
}
}}
autofocus
/>
</Show>
<div class={styles.dialogActions}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeDialog}>
Cancel
</button>
<button
classList={{
[styles.dialogPrimaryButton]: true,
[styles.dialogDangerButton]: isDangerDialogState(state()),
}}
type="button"
onClick={submitDialog}
>
{state().confirmLabel}
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
</div>
</Show>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,375 +0,0 @@
// Path: Frontend/src/components/shell/data/app-shell.context.tsx
import {
createContext,
createMemo,
createSignal,
onMount,
useContext,
type Accessor,
type JSX,
} from "solid-js";
import { Folder } from "../../../lib/icons";
import { resolveAPIBase } from "../../../lib/api";
import {
activeDepartment as fallbackActiveDepartment,
activeProject as fallbackActiveProject,
activeServer as fallbackActiveServer,
activeUserProfile as fallbackActiveUserProfile,
departmentItems as fallbackDepartmentItems,
organizationAdminDockActions,
personalDockActions,
projectItems as fallbackProjectItems,
railItems as fallbackRailItems,
workspaceTree as fallbackWorkspaceTree,
type ActiveDepartment,
type ActiveProject,
type ActiveServer,
type ActiveUserProfile,
type DepartmentItem,
type ProjectItem,
type RailItem,
type WorkspaceTreeNode,
} from "./shell.data";
type AppShellInstallation = {
id: string;
name: string;
mode: "personal" | "organizational" | string;
access: string;
protocol: string;
host: string;
isBootstrapped: boolean;
materializationStatus: "not_started" | "pending" | "running" | "succeeded" | "failed" | string;
materializationError?: string;
};
type AppShellAdmin = {
id: string;
email: string;
displayName: string;
isInstanceAdmin: boolean;
homeTitle: string;
};
type AppShellOrganization = {
id: string;
name: string;
slug: string;
};
type AppShellDepartment = {
id: string;
organizationId: string;
name: string;
slug: string;
};
type AppShellTeam = {
id: string;
organizationId: string;
departmentId?: string;
name: string;
slug: string;
};
type AppShellProject = {
id: string;
organizationId: string;
departmentId?: string;
teamId?: string;
name: string;
slug: string;
};
type AppShellWorkspace = {
id: string;
organizationId: string;
name: string;
slug: string;
kind: "organization" | "department" | "team" | "project" | string;
departmentId?: string;
teamId?: string;
projectId?: string;
};
type AppShellPayload = {
installation?: AppShellInstallation;
admin?: AppShellAdmin;
organizations: AppShellOrganization[];
departments: AppShellDepartment[];
teams: AppShellTeam[];
projects: AppShellProject[];
workspaces: AppShellWorkspace[];
};
const normalizeInstallation = (
installation: AppShellInstallation | null | undefined,
): AppShellInstallation | undefined => {
if (!installation) {
return undefined;
}
const materializationStatus = installation.materializationStatus?.trim()
? installation.materializationStatus
: installation.isBootstrapped
? "succeeded"
: "not_started";
const materializationError = installation.materializationError?.trim() || undefined;
return {
...installation,
materializationStatus,
materializationError,
};
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: normalizeInstallation(payload?.installation),
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
activeDepartment: Accessor<ActiveDepartment>;
projectItems: Accessor<readonly ProjectItem[]>;
departmentItems: Accessor<readonly DepartmentItem[]>;
workspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
activeUserProfile: Accessor<ActiveUserProfile>;
reload: () => Promise<void>;
};
const AppShellContext = createContext<AppShellContextValue>();
const buildAbbreviation = (name: string, fallback: string): string => {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) {
return fallback;
}
const abbreviation = parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
return abbreviation || fallback;
};
const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => {
if (!payload?.installation || payload.organizations.length === 0) {
return fallbackRailItems;
}
const kind = payload.installation.mode === "personal" ? "personal" : "organization";
const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host;
return payload.organizations.map((organization, index) => ({
id: organization.id,
label: serverName || organization.name,
abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"),
kind,
active: index === 0,
}));
};
const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => {
const installation = payload?.installation;
const organization = payload?.organizations[0];
if (!installation || !organization) {
return fallbackActiveServer;
}
const kind = installation.mode === "personal" ? "personal" : "organization";
const serverName = installation.name || organization.name || installation.host;
return {
id: installation.id,
name: serverName || fallbackActiveServer.name,
abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"),
kind,
connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined,
subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined,
dockActions: kind === "personal" ? personalDockActions : organizationAdminDockActions,
};
};
const buildProjectItems = (payload: AppShellPayload | null): readonly ProjectItem[] => {
if (!payload?.projects.length) {
return fallbackProjectItems;
}
return payload.projects.map((project, index) => ({
id: project.id,
name: project.name,
description: project.slug || "Persisted project workspace",
groupLabel: payload.departments.find((department) => department.id === project.departmentId)?.name || "Projects",
parentLabel:
payload.teams.find((team) => team.id === project.teamId)?.name ||
payload.departments.find((department) => department.id === project.departmentId)?.name ||
"Shared project",
meta: (() => {
const workspaceCount = payload.workspaces.filter((workspace) => workspace.projectId === project.id).length;
return workspaceCount > 0 ? `${workspaceCount} workspace${workspaceCount === 1 ? "" : "s"}` : undefined;
})(),
active: index === 0,
}));
};
const buildActiveProject = (payload: AppShellPayload | null): ActiveProject => {
const firstProject = payload?.projects[0];
if (!firstProject) {
return fallbackActiveProject;
}
return {
id: firstProject.id,
name: firstProject.name,
};
};
const buildDepartmentItems = (payload: AppShellPayload | null): readonly DepartmentItem[] => {
if (!payload?.departments.length) {
return fallbackDepartmentItems;
}
return payload.departments.map((department, index) => ({
id: department.id,
name: department.name,
teams: payload.teams
.filter((team) => team.departmentId === department.id)
.map((team) => team.name),
active: index === 0,
}));
};
const buildActiveDepartment = (payload: AppShellPayload | null): ActiveDepartment => {
const firstDepartment = payload?.departments[0];
if (!firstDepartment) {
return fallbackActiveDepartment;
}
const firstTeamName = payload?.teams.find((team) => team.departmentId === firstDepartment.id)?.name ?? "";
return {
id: firstDepartment.id,
name: firstDepartment.name,
teamName: firstTeamName,
};
};
const buildWorkspaceTree = (payload: AppShellPayload | null): readonly WorkspaceTreeNode[] => {
if (!payload?.projects.length) {
return fallbackWorkspaceTree;
}
// The workspace tree should represent items inside the current project, not the
// project container itself. We do not have project-contents hydration yet, so
// return an empty tree rather than showing the project root as a fake item.
return [];
};
const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProfile => {
if (!payload?.admin) {
return fallbackActiveUserProfile;
}
const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name;
const departmentName = payload.departments[0]?.name;
return {
name: payload.admin.displayName,
email: payload.admin.email,
roleLabel: payload.admin.isInstanceAdmin ? "Instance admin" : "Member",
contextLabel: departmentName ? `${organizationName}${departmentName}` : organizationName,
};
};
export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Element => {
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = createSignal("");
const [payload, setPayload] = createSignal<AppShellPayload | null>(null);
const load = async (): Promise<void> => {
setStatus("loading");
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/app-shell`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as {
data?: AppShellPayload;
error?: { message?: string } | string;
message?: string;
};
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok || !body.data) {
throw new Error(errorMessage || "Failed to load app shell state.");
}
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
setError(loadError instanceof Error ? loadError.message : "Failed to load app shell state.");
}
};
onMount(() => {
void load();
});
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),
activeDepartment: createMemo(() => buildActiveDepartment(payload())),
projectItems: createMemo(() => buildProjectItems(payload())),
departmentItems: createMemo(() => buildDepartmentItems(payload())),
workspaceTree: createMemo(() => buildWorkspaceTree(payload())),
activeUserProfile: createMemo(() => buildActiveUserProfile(payload())),
reload: load,
};
return <AppShellContext.Provider value={value}>{props.children}</AppShellContext.Provider>;
};
export const useAppShellData = (): AppShellContextValue => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error("useAppShellData must be used within AppShellDataProvider");
}
return context;
};
@@ -1,734 +0,0 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
ListCollapse,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
groupLabel?: string;
parentLabel?: string;
meta?: string;
active?: boolean;
};
export type ProjectMenuTarget =
| {
id: string;
label: string;
kind: "surface";
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "project";
};
export type ProjectContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly ProjectContextMenuAction[];
};
export type ProjectContextMenuSection = {
id: string;
label?: string;
items: readonly ProjectContextMenuAction[];
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
// Keep this open-ended so future server-driven or plugin-provided item types do
// not require a frontend source edit before they can be represented safely.
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceFolderNode = {
id: string;
path?: string;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: string;
path?: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
active?: boolean;
meta?: string;
children?: undefined;
};
export type WorkspaceTreeNode = WorkspaceFolderNode | WorkspaceItemNode;
export type WorkspaceItemTypeDefinition = {
id: WorkspaceItemTypeId;
label: string;
shortLabel: string;
icon: ShellIcon;
noun: string;
actionPrefix: string;
defaultCreateLabel: string;
includeInWorkspaceCreate?: boolean;
description?: string;
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget =
| {
id: string;
label: string;
kind: WorkspaceStaticKind;
}
| {
id: string;
label: string;
kind: "folder";
}
| {
id: string;
label: string;
kind: "item";
itemType: WorkspaceItemTypeId;
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export const firstPartyWorkspaceItemTypes: readonly WorkspaceItemTypeDefinition[] = [
{
id: "core.doc",
label: "Doc",
shortLabel: "Doc",
icon: FileText,
noun: "doc",
actionPrefix: "doc",
defaultCreateLabel: "New doc",
includeInWorkspaceCreate: true,
description: "Rich text documents and notes.",
},
{
id: "core.board.kanban",
label: "Kanban board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "board",
defaultCreateLabel: "New board",
includeInWorkspaceCreate: true,
description: "Default board-style workspace item.",
},
{
id: "core.board.list",
label: "List board",
shortLabel: "Board",
icon: LayoutGrid,
noun: "board",
actionPrefix: "list-board",
defaultCreateLabel: "New list board",
description: "Alternate first-party board view prepared for the future registry.",
},
] as const;
const workspaceItemTypeMap = new Map<WorkspaceItemTypeId, WorkspaceItemTypeDefinition>(
firstPartyWorkspaceItemTypes.map((definition) => [definition.id, definition]),
);
const createUnknownWorkspaceItemTypeDefinition = (
itemType: WorkspaceItemTypeId,
): WorkspaceItemTypeDefinition => ({
id: itemType,
label: "Item",
shortLabel: "Item",
icon: FileText,
noun: "item",
actionPrefix: "item",
defaultCreateLabel: "New item",
description: "Fallback definition for unknown or future workspace item types.",
});
export const getWorkspaceItemTypeDefinition = (itemType: WorkspaceItemTypeId): WorkspaceItemTypeDefinition => {
return workspaceItemTypeMap.get(itemType) ?? createUnknownWorkspaceItemTypeDefinition(itemType);
};
export const getWorkspaceNodeIcon = (node: WorkspaceTreeNode): ShellIcon =>
node.kind === "folder" ? node.icon : getWorkspaceItemTypeDefinition(node.itemType).icon;
const getWorkspaceCreateActions = (): readonly WorkspaceContextMenuAction[] => [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
...firstPartyWorkspaceItemTypes
.filter((definition) => definition.includeInWorkspaceCreate)
.map((definition) => ({
id: `create-${definition.actionPrefix}`,
label: definition.defaultCreateLabel,
shortcut:
definition.id === "core.board.kanban"
? ({ modifiers: ["alt"], key: "b" } as const)
: definition.id === "core.doc"
? ({ modifiers: ["alt"], key: "d" } as const)
: undefined,
})),
];
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "item":
return getWorkspaceItemTypeDefinition(target.itemType).shortLabel;
}
};
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
...(node.kind === "folder"
? { kind: "folder" as const }
: {
kind: "item" as const,
itemType: node.itemType,
}),
});
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
export const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
// Server shell scaffold data
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
// Workspace framing scaffold data
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{
id: "general",
name: "General",
description: "Default shared project",
groupLabel: "Shared space",
parentLabel: "Workspace home",
meta: "1 workspace",
active: true,
},
{
id: "operations",
name: "Operations",
description: "Cross-team planning and delivery",
groupLabel: "Team folders",
parentLabel: "Shared Services",
meta: "2 workspaces",
},
{
id: "hiring",
name: "Hiring",
description: "Candidate pipeline and interview loops",
groupLabel: "Team folders",
parentLabel: "People Ops",
meta: "1 workspace",
},
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
// Sidebar and topbar scaffold data
// These static entries stay pinned in both desktop and mobile workspace navigation.
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
// Freeform workspace tree scaffold: folders are structural, while non-folder
// nodes already flow through the future-safe itemType registry seam.
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "item", itemType: "core.board.kanban", active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "item", itemType: "core.doc" },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "item", itemType: "core.doc" },
{ id: "signals-board", label: "Signals", kind: "item", itemType: "core.board.kanban", meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "item", itemType: "core.doc" },
{ id: "review-board", label: "Review Queue", kind: "item", itemType: "core.board.kanban" },
],
},
{ id: "general-notes", label: "General Notes", kind: "item", itemType: "core.doc" },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
// target kinds and action grouping should stay shared across workspace surfaces.
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = getWorkspaceCreateActions();
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
},
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{
id: "open",
items: [
{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "organize",
label: undefined,
items: [
{ id: "move-folder", label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "item": {
const definition = getWorkspaceItemTypeDefinition(target.itemType);
const actionPrefix = definition.actionPrefix;
const nounLabel = definition.noun;
return [
{
id: `${actionPrefix}-primary`,
items: [
{ id: `open-${actionPrefix}`, label: `Open ${nounLabel}`, shortcut: { key: "enter" } },
{ id: `rename-${actionPrefix}`, label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: `move-${actionPrefix}`, label: "Move…", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: `delete-${actionPrefix}`, label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
}
};
const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "new-project", label: "New project" },
{ id: "new-folder", label: "New folder" },
] as const;
const getProjectFolderDangerActions = (): readonly ProjectContextMenuAction[] =>
[
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
] as const;
export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface",
label,
kind: "surface",
});
export const createProjectFolderTarget = (id: string, label: string): ProjectMenuTarget => ({
id,
label,
kind: "folder",
});
export const createProjectTarget = (project: ProjectItem): ProjectMenuTarget => ({
id: project.id,
label: project.name,
kind: "project",
});
export const getProjectContextMenuEyebrow = (target: ProjectMenuTarget): string => {
switch (target.kind) {
case "surface":
return "Projects";
case "folder":
return "Folder";
case "project":
return "Project";
}
};
export const getProjectContextMenuSections = (target: ProjectMenuTarget): readonly ProjectContextMenuSection[] => {
const createActions = getProjectCreateActions();
switch (target.kind) {
case "surface":
return [
{
id: "create",
items: createActions,
},
] as const;
case "folder":
return [
{
id: "create",
items: createActions,
},
{
id: "organize",
items: getProjectFolderDangerActions(),
},
] as const;
case "project":
return [
{
id: "create",
items: createActions,
},
] as const;
}
};
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;
@@ -1,393 +0,0 @@
import { createEffect, createSignal, type Accessor, type Setter } from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import { Folder } from "../../../lib/icons";
import type { WorkspaceItemTypeId, WorkspaceTreeNode } from "../data/shell.data";
import { isUuidString } from "./navTreeDnd";
type PersistedWorkspaceTreeNodeRecord = {
id: string;
path: string;
label: string;
kind: "folder" | "item";
itemType?: string;
children?: PersistedWorkspaceTreeNodeRecord[];
};
type WorkspaceTreeResponse = {
data?: {
nodes?: PersistedWorkspaceTreeNodeRecord[];
};
error?: string;
message?: string;
};
type WorkspaceMutationResponse = {
error?: string;
message?: string;
};
const buildPersistedWorkspaceTreeNodes = (
nodes: readonly PersistedWorkspaceTreeNodeRecord[],
): WorkspaceTreeNode[] =>
nodes.map((node) =>
node.kind === "folder"
? {
id: node.id,
path: node.path,
label: node.label,
kind: "folder",
icon: Folder,
children: buildPersistedWorkspaceTreeNodes(node.children ?? []),
}
: {
id: node.id,
path: node.path,
label: node.label,
kind: "item",
itemType: node.itemType ?? "core.board.kanban",
}
);
const readPersistedWorkspaceTreeNodes = (body: WorkspaceTreeResponse): PersistedWorkspaceTreeNodeRecord[] =>
Array.isArray(body.data?.nodes) ? body.data.nodes : [];
const findNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.kind !== "folder") {
continue;
}
const nestedMatch = findNodeById(node.children ?? [], nodeId);
if (nestedMatch) {
return nestedMatch;
}
}
return null;
};
type UseWorkspaceTreeDataOptions = {
activeProjectId: Accessor<string>;
fallbackWorkspaceTree: Accessor<readonly WorkspaceTreeNode[]>;
};
type UseWorkspaceTreeDataResult = {
workspaceTreeNodes: Accessor<readonly WorkspaceTreeNode[]>;
setWorkspaceTreeNodes: Setter<readonly WorkspaceTreeNode[]>;
resolveFolderPath: (folderId: string) => string | null;
resolveItemPath: (itemId: string) => string | null;
createFolder: (name: string, parentId: string | null) => Promise<boolean>;
renameFolder: (folderId: string, name: string) => Promise<boolean>;
deleteFolder: (folderId: string) => Promise<boolean>;
moveFolder: (folderId: string, parentId: string | null, targetIndex: number) => Promise<boolean>;
createItem: (name: string, itemType: WorkspaceItemTypeId, parentId: string | null) => Promise<boolean>;
deleteItem: (itemId: string) => Promise<boolean>;
moveItem: (itemId: string, parentId: string | null, targetIndex: number) => Promise<boolean>;
};
export const useWorkspaceTreeData = (
options: UseWorkspaceTreeDataOptions,
): UseWorkspaceTreeDataResult => {
const [persistedNodes, setPersistedNodes] = createSignal<readonly PersistedWorkspaceTreeNodeRecord[]>([]);
const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal<readonly WorkspaceTreeNode[]>(
options.fallbackWorkspaceTree(),
);
let latestPersistedTreeRequest = 0;
const syncWorkspaceTree = (): void => {
const nextTree = options.activeProjectId()
? buildPersistedWorkspaceTreeNodes(persistedNodes())
: options.fallbackWorkspaceTree();
setWorkspaceTreeNodes(nextTree);
};
const loadPersistedTree = async (projectId: string): Promise<boolean> => {
const requestId = latestPersistedTreeRequest + 1;
latestPersistedTreeRequest = requestId;
if (!projectId || !isUuidString(projectId)) {
setPersistedNodes([]);
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as WorkspaceTreeResponse;
if (requestId !== latestPersistedTreeRequest) {
return false;
}
if (!response.ok) {
throw new Error(body.message || "Failed to load project tree.");
}
setPersistedNodes(readPersistedWorkspaceTreeNodes(body));
return true;
} catch (error) {
if (requestId !== latestPersistedTreeRequest) {
return false;
}
console.error(error);
setPersistedNodes([]);
return false;
}
};
const resolveFolderPath = (folderId: string): string | null => {
const node = findNodeById(workspaceTreeNodes(), folderId);
return node?.kind === "folder" ? node.path ?? null : null;
};
const resolveItemPath = (itemId: string): string | null => {
const node = findNodeById(workspaceTreeNodes(), itemId);
return node?.kind === "item" ? node.path ?? null : null;
};
const refreshAfterMutation = async (projectId: string, response: Response): Promise<boolean> => {
const body = (await response.json()) as WorkspaceMutationResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to persist project tree mutation.");
}
return loadPersistedTree(projectId);
};
const createFolder = async (name: string, parentId: string | null): Promise<boolean> => {
const projectId = options.activeProjectId();
if (!projectId || !isUuidString(projectId)) {
return false;
}
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (parentId && !parentFolderPath) {
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
parentFolderId: parentFolderPath,
}),
});
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const renameFolder = async (folderId: string, name: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
if (!projectId || !isUuidString(projectId) || !folderPath) {
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: folderPath,
name,
}),
});
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const deleteFolder = async (folderId: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
if (!projectId || !isUuidString(projectId) || !folderPath) {
return false;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderPath)}`,
{
method: "DELETE",
headers: {
Accept: "application/json",
},
},
);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const moveFolder = async (folderId: string, parentId: string | null, targetIndex: number): Promise<boolean> => {
const projectId = options.activeProjectId();
const folderPath = resolveFolderPath(folderId);
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (!projectId || !isUuidString(projectId) || !folderPath) {
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: folderPath,
folderNodeId: folderId,
parentFolderId: parentFolderPath,
parentNodeId: parentId,
targetIndex,
}),
});
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const createItem = async (
name: string,
itemType: WorkspaceItemTypeId,
parentId: string | null,
): Promise<boolean> => {
const projectId = options.activeProjectId();
if (!projectId || !isUuidString(projectId)) {
return false;
}
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (parentId && !parentFolderPath) {
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
itemType,
parentFolderId: parentFolderPath,
}),
});
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const deleteItem = async (itemId: string): Promise<boolean> => {
const projectId = options.activeProjectId();
const itemPath = resolveItemPath(itemId);
if (!projectId || !isUuidString(projectId) || !itemPath) {
return false;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/tree/items?itemId=${encodeURIComponent(itemPath)}`,
{
method: "DELETE",
headers: {
Accept: "application/json",
},
},
);
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
const moveItem = async (itemId: string, parentId: string | null, targetIndex: number): Promise<boolean> => {
const projectId = options.activeProjectId();
const itemPath = resolveItemPath(itemId);
const parentFolderPath = parentId ? resolveFolderPath(parentId) : null;
if (!projectId || !isUuidString(projectId) || !itemPath) {
return false;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/items/move`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
itemId: itemPath,
itemNodeId: itemId,
parentFolderId: parentFolderPath,
parentNodeId: parentId,
targetIndex,
}),
});
return refreshAfterMutation(projectId, response);
} catch (error) {
console.error(error);
return false;
}
};
createEffect(() => {
syncWorkspaceTree();
});
createEffect(() => {
void loadPersistedTree(options.activeProjectId());
});
return {
workspaceTreeNodes,
setWorkspaceTreeNodes,
resolveFolderPath,
resolveItemPath,
createFolder,
renameFolder,
deleteFolder,
moveFolder,
createItem,
deleteItem,
moveItem,
};
};
@@ -1,7 +1,7 @@
import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons"; import { ChevronDown } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { type DepartmentItem } from "../data/shell.data"; import { type DepartmentItem } from "../../app-shell/data/shell.data";
import styles from "./DepartmentSelector.module.scss"; import styles from "./DepartmentSelector.module.scss";
export const DepartmentSelector = (): JSX.Element => { export const DepartmentSelector = (): JSX.Element => {
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons"; import { Bell } from "../../../lib/icons";
import { unreadNotificationCount } from "../data/shell.data"; import { unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsButton.module.scss"; import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = { type NotificationsButtonProps = {
@@ -1,6 +1,6 @@
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons"; import { Bell, Settings } from "../../../lib/icons";
import { notificationItems, unreadNotificationCount } from "../data/shell.data"; import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
import styles from "./NotificationsMenu.module.scss"; import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = { type NotificationsMenuProps = {
@@ -1,7 +1,7 @@
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { profileMenuSections } from "../data/shell.data"; import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss"; import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = { type ProfileMenuProps = {
@@ -1,7 +1,7 @@
// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx // Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { Moon, Sun } from "../../../lib/icons"; import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss"; import styles from "./ThemeToggle.module.scss";
@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx // Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js"; import { For, type JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime"; import type { Theme } from "../../../helper/themeRuntime";
import { topBarActions } from "../data/shell.data"; import { topBarActions } from "../../app-shell/data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector"; import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav"; import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
@@ -1,4 +1,4 @@
// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx // Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { User } from "../../../lib/icons"; import { User } from "../../../lib/icons";
@@ -0,0 +1,25 @@
/* Path: Frontend/src/components/workspace-home/WorkspaceHome.module.scss */
.viewport {
--workspace-content-max-width: var(--content-width-wide);
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.title {
@include text-display;
font-family: var(--font-family-display);
max-width: 12ch;
}
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
}
}
@@ -0,0 +1,15 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome.tsx
import { type JSX } from "solid-js";
import { useAppShellData } from "../app-shell/data/app-shell.context";
import styles from "./WorkspaceHome.module.scss";
export const WorkspaceHome = (): JSX.Element => {
const appShellData = useAppShellData();
return (
<main class={styles.viewport} data-ui="workspace-home">
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
</main>
);
};
@@ -1,271 +0,0 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { Show, createMemo, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { useAppShellData } from "../../shell/data/app-shell.context";
import { bootstrapPersonaDefinitions } from "./WorkspaceHome.data";
import { useWorkspaceHomeWizard } from "./WorkspaceHome.hook";
import styles from "./WorkspaceHome.module.scss";
import { BootstrapAdminStep, BootstrapFinishingState, BootstrapInstanceStep, BootstrapModeStep, BootstrapPersonaStep, BootstrapStructureStep, BootstrapWizardProgress } from "./WorkspaceHome.parts";
type WorkspaceHomeProps = {
sidebarCollapsed: boolean;
onToggleSidebarCollapse: () => void;
};
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData();
const {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isWizardOpen,
setIsWizardOpen,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
handleCurrentStepSubmit,
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack,
navigateToVisibleStep,
} = useWorkspaceHomeWizard(appShellData);
const isBootstrapPersisted = createMemo(() => appShellData.installation()?.isBootstrapped ?? false);
const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
return (
<>
<main class={styles.viewport} data-ui="workspace-home">
<div class={styles.workspaceTopBar} data-slot="workspace-home-top-bar">
<div class={styles.workspaceTopBarStart} data-slot="workspace-home-top-bar-start">
<button type="button" class={styles.workspaceCollapseButton} aria-label={sidebarToggleLabel()} title={sidebarToggleLabel()} data-slot="workspace-home-sidebar-toggle" onClick={props.onToggleSidebarCollapse}>
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
</div>
<div class={styles.workspaceTopBarCenter} data-slot="workspace-home-top-bar-center">
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span>
</div>
<div class={styles.workspaceTopBarEnd} data-slot="workspace-home-top-bar-end" aria-hidden="true" />
</div>
<section class={styles.hero} data-slot="workspace-home-hero">
<h1 class={styles.title}>{isBootstrapPersisted() ? appShellData.activeServer().name : "Server"}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapPersisted()}>
<div class={styles.heroActions}>
<button
type="button"
class={styles.primaryButton}
onClick={(): void => {
setIsWizardOpen(true);
}}
>
Open bootstrap wizard
</button>
</div>
</Show>
</section>
</main>
<Show when={isBootstrapStateResolved() && isWizardOpen()}>
<Portal>
<div class={styles.wizardLayer} data-ui="bootstrap-wizard" data-step={currentStep().id}>
<div class={styles.wizardBackdrop} aria-hidden="true" />
<section class={styles.wizardPanel} role="dialog" aria-modal="true" aria-labelledby="bootstrap-wizard-title" data-slot="bootstrap-wizard-panel">
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap Server
</h2>
</div>
<Show when={canDismissWizard()}>
<button
type="button"
class={styles.wizardCloseButton}
onClick={(): void => {
setIsWizardOpen(false);
}}
>
Close
</button>
</Show>
</header>
<Show
when={!showBootstrapFinishingState()}
fallback={
<BootstrapFinishingState
materializationState={materializationState()}
statusLabel={materializationStatusLabel()}
message={materializationMessage()}
isInFlight={isMaterializationInFlight()}
hasFailed={hasMaterializationFailed()}
onClose={(): void => {
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
}}
/>
}
>
<div class={styles.wizardBody}>
<Show when={currentStep().id !== "persona"}>
<BootstrapWizardProgress
steps={activeWizardSteps()}
currentStepId={currentStep().id}
currentWizardStepIndex={currentWizardStepIndex()}
stepState={stepState}
bootstrapStepCount={bootstrapStepCount()}
wizardProgressFillWidth={wizardProgressFillWidth()}
stepStatusLabel={stepStatusLabel}
onSelectStep={navigateToVisibleStep}
/>
</Show>
<div class={styles.wizardStepPanel} data-slot="bootstrap-wizard-step-panel">
<Show when={currentStep().id !== "persona" || statusLabel(currentStepState())}>
<div class={styles.sectionHeader}>
<Show when={currentStep().id !== "persona"}>
<div>
<span class={styles.wizardStepEyebrow}>{`Step ${currentWizardStepIndex() + 1} of ${bootstrapStepCount()}`}</span>
</div>
</Show>
<Show when={statusLabel(currentStepState())}>
<div class={styles.statusBadge} data-status={currentStepState().status}>
{statusLabel(currentStepState())}
</div>
</Show>
</div>
</Show>
<form class={styles.form} onSubmit={handleCurrentStepSubmit}>
<Show when={currentStep().id === "persona"}>
<BootstrapPersonaStep
personas={bootstrapPersonaDefinitions}
hasChosenPersona={hasChosenPersona()}
selectedPersona={selectedPersona()}
selectedPersonaIsAvailable={selectedPersonaIsAvailable()}
onSelectPersona={applyPersonaSelection}
/>
</Show>
<Show when={currentStep().id === "instance"}>
<BootstrapInstanceStep
instanceForm={instanceForm}
onProtocolChange={(value): void => setInstanceForm("protocol", value)}
onAccessChange={(value): void => setInstanceForm("access", value)}
onHostChange={(value): void => setInstanceForm("host", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
/>
</Show>
<Show when={currentStep().id === "mode"}>
<BootstrapModeStep
modeForm={modeForm}
structureForm={structureForm}
usesCondensedBootstrapFlow={usesCondensedBootstrapFlow()}
selectedPersona={selectedPersona()}
namePlaceholder={bootstrapNamePlaceholder()}
onNameChange={(value): void => setModeForm("name", value)}
onProjectNameChange={(value): void => setStructureForm("projectName", value)}
onTeamNameChange={(value): void => setStructureForm("teamName", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
/>
</Show>
<Show when={currentStep().id === "admin"}>
<BootstrapAdminStep
adminForm={adminForm}
onDisplayNameChange={(value): void => setAdminForm("displayName", value)}
onEmailChange={(value): void => setAdminForm("email", value)}
onPasswordChange={(value): void => setAdminForm("password", value)}
/>
</Show>
<Show when={currentStep().id === "structure"}>
<BootstrapStructureStep
mode={modeForm.mode}
structureForm={structureForm}
onDepartmentNameChange={(value): void => setStructureForm("departmentName", value)}
onTeamNameChange={(value): void => setStructureForm("teamName", value)}
onProjectNameChange={(value): void => setStructureForm("projectName", value)}
onShowTooltip={showFieldTooltip}
onHideTooltip={hideFieldTooltip}
/>
</Show>
<Show when={currentStep().id !== "persona"}>
<div class={styles.wizardFormActions}>
<button type="button" class={styles.secondaryButton} disabled={isFirstStep()} onClick={navigateBack}>
Back
</button>
<button type="submit" class={styles.primaryButton} disabled={currentStepState().status === "submitting"}>
{currentStep().buttonLabel}
</button>
</div>
</Show>
</form>
<Show when={currentStepState().error}>
<p class={styles.errorText}>{currentStepState().error}</p>
</Show>
</div>
</div>
</Show>
</section>
<Show when={fieldTooltip()}>
{(tooltip): JSX.Element => (
<div
class={styles.fieldTooltip}
data-placement={tooltip().placement}
style={{
left: `${tooltip().left}px`,
top: `${tooltip().top}px`,
}}
>
<div class={styles.fieldTooltipBubble}>{tooltip().text}</div>
</div>
)}
</Show>
</div>
</Portal>
</Show>
</>
);
};
@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx // Path: Frontend/src/components/workspace-navigation/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js"; import { For, Show, type JSX } from "solid-js";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { type RailItem } from "../data/shell.data"; import { type RailItem } from "../../app-shell/data/shell.data";
import styles from "./LeftRail.module.scss"; import styles from "./LeftRail.module.scss";
type RailEntryProps = { type RailEntryProps = {
@@ -0,0 +1,91 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.data.ts
import type { WorkspaceTreeNode } from "../../app-shell/data/shell.data";
export type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
export type MobileWorkspaceDialogState =
| {
kind: "text";
title: string;
message: string;
confirmLabel: string;
initialValue: string;
onConfirm: (value: string) => void;
}
| {
kind: "confirm";
title: string;
message: string;
confirmLabel: string;
tone?: "danger";
onConfirm: () => void;
};
export type MobileMoveTargetState = {
kind: "folder" | "item";
id: string;
label: string;
};
export type MobileMoveDestination = {
id: string | null;
label: string;
depth: number;
meta?: string;
};
export const collectMoveDestinations = (
nodes: readonly WorkspaceTreeNode[],
movingTarget: MobileMoveTargetState,
depth = 0,
ancestorBlocked = false,
): MobileMoveDestination[] => {
const destinations: MobileMoveDestination[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
if (!ancestorBlocked && !isBlockedFolder) {
destinations.push({
id: node.id,
label: node.label,
depth,
meta: "Folder",
});
}
destinations.push(
...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
);
}
return destinations;
};
export const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.kind !== "folder") {
continue;
}
const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
if (nestedMatch) {
return nestedMatch;
}
}
return null;
};
export const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
@@ -0,0 +1,273 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.hook.ts
import { createSignal } from "solid-js";
import { createLongPressGesture } from "../../../helper/createLongPressGesture";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import {
createWorkspaceSurfaceTarget,
getWorkspaceItemTypeDefinition,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceItemTypeId,
} from "../../app-shell/data/shell.data";
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
import {
collectMoveDestinations,
findTreeNodeById,
type MobileMoveDestination,
type MobileMoveTargetState,
type MobileWorkspaceDialogState,
} from "./MobileWorkspaceBrowser.data";
export const useMobileWorkspaceBrowser = () => {
const appShellData = useAppShellData();
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const [dialogState, setDialogState] = createSignal<MobileWorkspaceDialogState | null>(null);
const [dialogValue, setDialogValue] = createSignal("");
const [moveTarget, setMoveTarget] = createSignal<MobileMoveTargetState | null>(null);
const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
activeProjectId: () => appShellData.activeProject().id,
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
});
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const moveDestinations = (): MobileMoveDestination[] => {
const target = moveTarget();
if (!target) {
return [];
}
return [{ id: null, label: "Items root", depth: 0, meta: "Root" }, ...collectMoveDestinations(workspaceTreeNodes(), target)];
};
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
switch (actionId) {
case "create-doc":
return "core.doc";
case "create-board":
return "core.board.kanban";
case "create-list-board":
return "core.board.list";
default:
return null;
}
};
const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
const definition = getWorkspaceItemTypeDefinition(itemType);
void createItem(definition.defaultCreateLabel, itemType, parentId);
};
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const closeMoveSheet = (): void => {
setMoveTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget());
};
const closeDialog = (): void => {
setDialogState(null);
setDialogValue("");
};
const openMoveSheet = (target: MobileMoveTargetState): void => {
setMoveTarget(target);
};
const openTextDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "text" }>, "kind">): void => {
setDialogValue(config.initialValue);
setDialogState({ kind: "text", ...config });
};
const openConfirmDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "confirm" }>, "kind">): void => {
setDialogValue("");
setDialogState({ kind: "confirm", ...config });
};
const submitDialog = (): void => {
const state = dialogState();
if (!state) {
return;
}
if (state.kind === "text") {
const value = dialogValue().trim();
if (!value) {
return;
}
closeDialog();
state.onConfirm(value);
return;
}
closeDialog();
state.onConfirm();
};
const handleMoveDestinationSelect = (destinationId: string | null): void => {
const target = moveTarget();
if (!target) {
return;
}
const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
const targetIndex = destinationNode?.kind === "folder"
? destinationNode.children?.length ?? 0
: destinationId
? 0
: workspaceTreeNodes().length;
closeMoveSheet();
if (target.kind === "folder") {
void moveFolder(target.id, destinationId, targetIndex);
return;
}
void moveItem(target.id, destinationId, targetIndex);
};
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
const createItemType = resolveCreateItemType(action.id);
if (createItemType) {
switch (target.kind) {
case "workspace":
case "home":
createPersistedItem(createItemType, null);
return;
case "folder":
createPersistedItem(createItemType, target.id);
return;
case "settings":
case "item":
return;
}
}
switch (action.id) {
case "new-folder": {
if (target.kind === "settings" || target.kind === "item") {
return;
}
openTextDialog({
title: "New folder",
message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
confirmLabel: "Create",
initialValue: "Untitled folder",
onConfirm: (name) => {
void createFolder(name, target.kind === "folder" ? target.id : null);
},
});
return;
}
case "rename-folder": {
if (target.kind !== "folder") {
return;
}
openTextDialog({
title: "Rename folder",
message: `Update the name for "${target.label}".`,
confirmLabel: "Save",
initialValue: target.label,
onConfirm: (name) => {
if (name === target.label) {
return;
}
void renameFolder(target.id, name);
},
});
return;
}
case "move-folder": {
if (target.kind !== "folder") {
return;
}
openMoveSheet({ kind: "folder", id: target.id, label: target.label });
return;
}
case "delete-folder": {
if (target.kind !== "folder") {
return;
}
openConfirmDialog({
title: "Delete folder?",
message: `"${target.label}" and everything inside it will be removed.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteFolder(target.id);
},
});
return;
}
case "move-doc":
case "move-board":
case "move-list-board": {
if (target.kind !== "item") {
return;
}
openMoveSheet({ kind: "item", id: target.id, label: target.label });
return;
}
case "delete-doc":
case "delete-board":
case "delete-list-board": {
if (target.kind !== "item") {
return;
}
openConfirmDialog({
title: "Delete item?",
message: `"${target.label}" will be removed from the project tree.`,
confirmLabel: "Delete",
tone: "danger",
onConfirm: () => {
void deleteItem(target.id);
},
});
return;
}
default:
return;
}
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return {
appShellData,
actionSheetTarget,
openActionSheet,
closeActionSheet,
dialogState,
dialogValue,
setDialogValue,
closeDialog,
submitDialog,
moveTarget,
moveDestinations,
closeMoveSheet,
handleMoveDestinationSelect,
workspaceTreeNodes,
handleActionSelect,
workspaceLongPress,
openWorkspaceActionSheet,
};
};
@@ -0,0 +1,140 @@
// Path: Frontend/src/components/workspace-navigation/MobileWorkspaceBrowser/MobileWorkspaceBrowser.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { ChevronRight } from "../../../lib/icons";
import { createLongPressGesture } from "../../../helper/createLongPressGesture";
import {
createWorkspaceStaticTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
type SidebarItem,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../../app-shell/data/shell.data";
import styles from "./MobileWorkspaceBrowser.module.scss";
export const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = getWorkspaceNodeIcon(props.node);
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
data-slot="mobile-workspace-tree-row"
data-kind={props.node.kind}
data-item-type={props.node.kind === "item" ? props.node.itemType : undefined}
data-active={props.node.active ? "true" : "false"}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
export const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }} data-slot="mobile-workspace-static-row" data-active={props.item.active ? "true" : "false"}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
export const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-static-item"
data-target-kind={target.kind}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
export const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
data-slot="mobile-workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
@@ -0,0 +1,192 @@
import { For, Show, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { Plus, X } from "../../../lib/icons";
import {
workspaceStaticItems,
} from "../../app-shell/data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import { useMobileWorkspaceBrowser } from "./MobileWorkspaceBrowser.hook";
import { isDangerDialogState, type MobileWorkspaceBrowserProps } from "./MobileWorkspaceBrowser.data";
import { WorkspaceStaticRow, WorkspaceTreeBranch } from "./MobileWorkspaceBrowser.parts";
import styles from "./MobileWorkspaceBrowser.module.scss";
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const {
appShellData,
actionSheetTarget,
openActionSheet,
closeActionSheet,
dialogState,
dialogValue,
setDialogValue,
closeDialog,
submitDialog,
moveTarget,
moveDestinations,
closeMoveSheet,
handleMoveDestinationSelect,
workspaceTreeNodes,
handleActionSelect,
workspaceLongPress,
openWorkspaceActionSheet,
} = useMobileWorkspaceBrowser();
return (
<Show when={props.open}>
<div class={styles.browserLayer} data-ui="mobile-workspace-browser">
<section class={styles.sheet} aria-label="Mobile workspace browser" data-slot="mobile-workspace-sheet">
<header class={styles.sheetHeader} data-slot="mobile-workspace-header">
<div
class={styles.brandBlock}
data-slot="mobile-workspace-brand"
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{appShellData.activeProject().name}</strong>
<span class={styles.brandContext}>{appShellData.activeServer().name}</span>
</div>
<div class={styles.headerActions} data-slot="mobile-workspace-header-actions">
<button
class={styles.createButton}
type="button"
aria-label="Create"
data-slot="mobile-workspace-create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" data-slot="mobile-workspace-close" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody} data-slot="mobile-workspace-body">
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
<WorkspaceTreeBranch nodes={workspaceTreeNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</div>
</section>
<WorkspaceMobileActionSheet target={actionSheetTarget()} onClose={closeActionSheet} onSelect={handleActionSelect} />
<Show when={moveTarget()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.moveSheetLayer} data-ui="mobile-workspace-move-sheet">
<button class={styles.dialogBackdrop} type="button" aria-label="Close move sheet" onClick={closeMoveSheet} />
<section class={styles.moveSheet} aria-label={`Move ${state().label}`}>
<div class={styles.moveSheetHandle} aria-hidden="true" />
<div class={styles.moveSheetHeader}>
<div class={styles.moveSheetHeaderCopy}>
<span class={styles.moveSheetEyebrow}>Move {state().kind}</span>
<strong class={styles.moveSheetTitle}>{state().label}</strong>
<p class={styles.moveSheetMessage}>Choose a new location in the project tree.</p>
</div>
</div>
<div class={styles.moveSection}>
<span class={styles.moveSectionLabel}>Destination</span>
<div class={styles.moveDestinationList}>
<For each={moveDestinations()}>
{(destination): JSX.Element => (
<button
type="button"
class={styles.moveDestinationButton}
style={{ "--move-depth": `${destination.depth}` }}
onClick={(): void => handleMoveDestinationSelect(destination.id)}
>
<span class={styles.moveDestinationLabel}>{destination.label}</span>
<Show when={destination.meta}>
<span class={styles.moveDestinationMeta}>{destination.meta}</span>
</Show>
</button>
)}
</For>
</div>
</div>
<div class={styles.moveSheetFooter}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeMoveSheet}>
Cancel
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
<Show when={dialogState()}>
{(state): JSX.Element => (
<Portal>
<div class={styles.dialogLayer} data-ui="mobile-workspace-dialog">
<button class={styles.dialogBackdrop} type="button" aria-label="Close dialog" onClick={closeDialog} />
<section class={styles.dialogCard} aria-label={state().title}>
<div class={styles.dialogCopy}>
<strong class={styles.dialogTitle}>{state().title}</strong>
<p class={styles.dialogMessage}>{state().message}</p>
</div>
<Show when={state().kind === "text"}>
<input
class={styles.dialogInput}
type="text"
value={dialogValue()}
onInput={(event): void => {
setDialogValue(event.currentTarget.value);
}}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
submitDialog();
}
}}
autofocus
/>
</Show>
<div class={styles.dialogActions}>
<button class={styles.dialogSecondaryButton} type="button" onClick={closeDialog}>
Cancel
</button>
<button
classList={{
[styles.dialogPrimaryButton]: true,
[styles.dialogDangerButton]: isDangerDialogState(state()),
}}
type="button"
onClick={submitDialog}
>
{state().confirmLabel}
</button>
</div>
</section>
</div>
</Portal>
)}
</Show>
</div>
</Show>
);
};
@@ -7,7 +7,7 @@ import {
type ProjectContextMenuAction, type ProjectContextMenuAction,
type ProjectMenuTarget, type ProjectMenuTarget,
type WorkspaceContextMenuShortcut, type WorkspaceContextMenuShortcut,
} from "../data/shell.data"; } from "../../app-shell/data/shell.data";
import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss"; import styles from "../WorkspaceContextMenu/WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows"; type ShortcutPlatform = "mac" | "windows";
@@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup } from "solid-js"; import { createEffect, createSignal, onCleanup } from "solid-js";
import type { ProjectMenuTarget } from "../data/shell.data"; import type { ProjectMenuTarget } from "../../app-shell/data/shell.data";
type ProjectContextMenuState = { type ProjectContextMenuState = {
target: ProjectMenuTarget; target: ProjectMenuTarget;
@@ -0,0 +1,144 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.data.ts
import type { JSX } from "solid-js";
import type {
NavTreeAdapter,
NavTreeDropTarget,
} from "../shared/navTreeDnd";
import type { ProjectItem } from "../../app-shell/data/shell.data";
export type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
export type ProjectFolderNode = {
kind: "folder";
id: string;
path: string;
label: string;
meta?: string;
children: ProjectTreeNode[];
};
export type ProjectLeafNode = {
kind: "project";
item: ProjectItem;
};
export type ProjectTreeNode = ProjectFolderNode | ProjectLeafNode;
export type PersistedProjectFolderRecord = {
id: string;
path: string;
label: string;
children: PersistedProjectFolderRecord[];
};
export type ProjectFoldersResponse = {
data?: {
folders?: PersistedProjectFolderRecord[];
renamedFolder?: PersistedProjectFolderRecord;
movedFolder?: PersistedProjectFolderRecord;
previousFolderId?: string;
previousFolderPath?: string;
};
error?: string;
message?: string;
};
export type PendingProjectFolderDraft = {
parentId: string | null;
depth: number;
};
export type PendingProjectFolderRename = {
folderId: string;
depth: number;
};
export type ProjectDragTarget = NavTreeDropTarget;
export type ProjectDragState = {
draggedNodeId: string;
dropTarget: ProjectDragTarget | null;
};
export type DragGhostPosition = {
x: number;
y: number;
};
export type ProjectFolderBranchProps = {
nodes: readonly ProjectTreeNode[];
depth: number;
parentId: string | null;
selectedProjectId: string;
isFolderCollapsed: (folderId: string) => boolean;
onToggleFolder: (folderId: string) => void;
onSelectProject: (projectId: string) => void;
onOpenFolderMenu: (event: MouseEvent, folder: ProjectFolderNode) => void;
onOpenProjectMenu: (event: MouseEvent, item: ProjectItem) => void;
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode) => void;
pendingFolderDraft: PendingProjectFolderDraft | null;
pendingFolderName: string;
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingProjectFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
dragState: ProjectDragState | null;
isTreeClickSuppressed: () => boolean;
};
export const LONG_PRESS_MS = 320;
export const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
node.kind === "folder" ? node.id : node.item.id;
export const buildPersistedFolderNodes = (folders: readonly PersistedProjectFolderRecord[] = []): ProjectTreeNode[] =>
folders.map((folder) => ({
kind: "folder",
id: folder.id,
path: folder.path,
label: folder.label,
children: buildPersistedFolderNodes(folder.children ?? []),
}));
export const buildProjectTree = (
items: readonly ProjectItem[],
folders: readonly PersistedProjectFolderRecord[] = [],
): ProjectTreeNode[] => [
...items.map((item) => ({
kind: "project" as const,
item,
})),
...buildPersistedFolderNodes(folders),
];
export const countProjectFolderSiblingsBeforeIndex = (
siblings: readonly ProjectTreeNode[],
index: number,
): number => siblings.slice(0, index).filter((node) => node.kind === "folder").length;
export const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
export const projectTreeAdapter: NavTreeAdapter<ProjectTreeNode> = {
getNodeId: getProjectTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? node.children : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: [...children],
}
: node,
};
@@ -0,0 +1,671 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.hook.ts
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import {
createProjectFolderTarget,
createProjectSurfaceTarget,
createProjectTarget,
type ProjectItem,
type ProjectMenuTarget,
} from "../../app-shell/data/shell.data";
import {
collectBranchNodeIds,
findTreeNodeDepth,
findTreeNodeLocation,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
} from "../shared/navTreeDnd";
import { createProjectContextMenuController } from "../ProjectContextMenu/createProjectContextMenuController";
import {
LONG_PRESS_MS,
buildProjectTree,
countProjectFolderSiblingsBeforeIndex,
projectTreeAdapter,
readPersistedFolders,
type DragGhostPosition,
type PendingProjectFolderDraft,
type PendingProjectFolderRename,
type PersistedProjectFolderRecord,
type ProjectDragState,
type ProjectFolderNode,
type ProjectSelectorProps,
type ProjectTreeNode,
} from "./ProjectSelector.data";
import {
createProjectFolderRequest,
deleteProjectFolderRequest,
fetchProjectFolders,
isValidProjectFolderProjectId,
moveProjectFolderRequest,
renameProjectFolderRequest,
} from "./projectFolders.api";
export const useProjectSelector = (props: ProjectSelectorProps) => {
const appShellData = useAppShellData();
const [selectedProject, setSelectedProject] = createSignal(appShellData.activeProject());
const [drawerTop, setDrawerTop] = createSignal<number>(0);
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
const [persistedFolders, setPersistedFolders] = createSignal<readonly PersistedProjectFolderRecord[]>([]);
const [projectTreeNodes, setProjectTreeNodes] = createSignal<ProjectTreeNode[]>(buildProjectTree(appShellData.projectItems(), persistedFolders()));
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingProjectFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingProjectFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<ProjectDragState | null>(null);
const [dragGhostPosition, setDragGhostPosition] = createSignal<DragGhostPosition>({ x: 0, y: 0 });
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
let triggerRef: HTMLButtonElement | undefined;
let contextMenuRef: HTMLDivElement | undefined;
let longPressTimer: number | undefined;
let suppressClickTimer: number | undefined;
let lastSelectedProjectId: string | null = null;
let latestPersistedFoldersRequest = 0;
const contextMenu = createProjectContextMenuController();
const setRootRef = (element: HTMLDivElement | undefined): void => {
rootRef = element;
};
const setTriggerRef = (element: HTMLButtonElement | undefined): void => {
triggerRef = element;
};
const setContextMenuElement = (element: HTMLDivElement | undefined): void => {
contextMenuRef = element;
if (element) {
contextMenu.setMenuRef(element);
}
};
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const updateDragGhostPosition = (x: number, y: number): void => {
setDragGhostPosition({ x: x + 18, y: y + 18 });
};
const draggedNode = (): ProjectTreeNode | null => {
const currentDragState = dragState();
if (!currentDragState) {
return null;
}
return findTreeNodeLocation(projectTreeNodes(), currentDragState.draggedNodeId, projectTreeAdapter)?.node ?? null;
};
const draggedNodeMeta = (): string => {
const node = draggedNode();
if (!node) {
return "";
}
return node.kind === "folder" ? "Folder" : "Project";
};
const suppressTreeClickTemporarily = (): void => {
setSuppressNextTreeClick(true);
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
suppressClickTimer = window.setTimeout(() => {
setSuppressNextTreeClick(false);
suppressClickTimer = undefined;
}, 80);
};
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const toggleFolder = (folderId: string): void => {
setCollapsedFolderIds((current) => (current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId]));
};
const syncProjectTree = (): void => {
const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
setProjectTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
};
const resetProjectTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
};
const collapseAllFolders = (): void => {
setCollapsedFolderIds(folderIds());
};
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const folderCount = totalFolderCount();
return folderCount > 0 && collapsedFolderIds().length >= folderCount;
};
const toggleAllFolders = (): void => {
if (areAllFoldersCollapsed()) {
expandAllFolders();
return;
}
collapseAllFolders();
};
const loadPersistedFolders = async (projectId: string): Promise<void> => {
const requestId = latestPersistedFoldersRequest + 1;
latestPersistedFoldersRequest = requestId;
if (!isValidProjectFolderProjectId(projectId) || !isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const body = await fetchProjectFolders(projectId);
if (requestId !== latestPersistedFoldersRequest) {
return;
}
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
if (requestId !== latestPersistedFoldersRequest) {
return;
}
console.error(error);
setPersistedFolders([]);
}
};
createEffect(() => {
setSelectedProject(appShellData.activeProject());
});
createEffect(() => {
syncProjectTree();
});
createEffect(() => {
const projectId = selectedProject().id;
if (lastSelectedProjectId === null) {
lastSelectedProjectId = projectId;
return;
}
if (projectId === lastSelectedProjectId) {
return;
}
lastSelectedProjectId = projectId;
resetProjectTreeInteractionState();
});
createEffect(() => {
void loadPersistedFolders(selectedProject().id);
});
onMount(() => {
if (triggerRef) {
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight + 8);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
}
const handlePointerDown = (event: PointerEvent): void => {
if (!props.isOpen || !rootRef) {
return;
}
const target = event.target;
if (target instanceof Node && rootRef.contains(target)) {
return;
}
if (target instanceof Node && contextMenuRef?.contains(target)) {
return;
}
props.onClose();
};
const handlePointerUp = (): void => {
clearLongPressTimer();
const nextDragState = dragState();
if (!nextDragState?.dropTarget) {
if (nextDragState) {
suppressTreeClickTemporarily();
}
setDragState(null);
return;
}
suppressTreeClickTemporarily();
const currentNodes = projectTreeNodes();
const draggedLocation = findTreeNodeLocation(currentNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const canPersistMove = isUuidString(selectedProject().id);
const draggedFolderPath = draggedLocation?.node.kind === "folder" ? draggedLocation.node.path : null;
const previewNodes = moveTreeNode(currentNodes, nextDragState.draggedNodeId, nextDragState.dropTarget, projectTreeAdapter);
const previewLocation = findTreeNodeLocation(previewNodes, nextDragState.draggedNodeId, projectTreeAdapter);
const persistedParentLocation = previewLocation?.parentId ? findTreeNodeLocation(previewNodes, previewLocation.parentId, projectTreeAdapter) : null;
const persistedParentFolderPath = persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.path : null;
const previewSiblings = previewLocation?.parentId ? persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.children : [] : previewNodes;
const targetIndex = previewLocation ? countProjectFolderSiblingsBeforeIndex(previewSiblings, previewLocation.index) : 0;
if (canPersistMove && draggedLocation?.node.kind === "folder" && draggedFolderPath && (!previewLocation?.parentId || persistedParentLocation?.node.kind === "folder")) {
void movePersistedFolder(
draggedFolderPath,
persistedParentFolderPath,
draggedLocation.node.id,
persistedParentLocation?.node.kind === "folder" ? persistedParentLocation.node.id : null,
targetIndex,
);
} else {
setProjectTreeNodes((current) => moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget!, projectTreeAdapter));
}
setDragState(null);
};
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
return;
}
clearLongPressTimer();
if (dragState()) {
setDragState(null);
return;
}
if (!props.isOpen) {
return;
}
props.onClose();
triggerRef?.focus();
};
const handlePointerMove = (event: PointerEvent): void => {
if (!dragState()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
};
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("keydown", handleEscape);
onCleanup(() => {
clearLongPressTimer();
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("keydown", handleEscape);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
if (!location || location.node.kind !== "project") {
return;
}
setSelectedProject({ id: location.node.item.id, name: location.node.item.name });
props.onClose();
};
const beginFolderDraft = (parentId: string | null, depth: number): void => {
if (parentId) {
setCollapsedFolderIds((current) => current.filter((id) => id !== parentId));
}
setPendingFolderRename(null);
setPendingFolderRenameName("");
setPendingFolderName("");
setPendingFolderDraft({ parentId, depth });
};
const beginFolderRename = (folderId: string, label: string, depth: number): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename({ folderId, depth });
setPendingFolderRenameName(label);
};
const resolveFolderPath = (folderId: string): string | null => {
const location = findTreeNodeLocation(projectTreeNodes(), folderId, projectTreeAdapter);
return location && location.node.kind === "folder" ? location.node.path : null;
};
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderDraft(null);
setPendingFolderName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolder();
return;
}
const parentFolderPath = draft.parentId ? resolveFolderPath(draft.parentId) : null;
if (draft.parentId && !parentFolderPath) {
cancelPendingFolder();
return;
}
try {
const body = await createProjectFolderRequest(projectId, name, parentFolderPath);
setPersistedFolders(readPersistedFolders(body));
setPendingFolderDraft(null);
setPendingFolderName("");
} catch (error) {
console.error(error);
}
};
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const projectId = selectedProject().id;
if (!folderId || !isUuidString(projectId)) {
return;
}
const folderPath = resolveFolderPath(folderId);
if (!folderPath) {
return;
}
try {
const body = await deleteProjectFolderRequest(projectId, folderPath);
setPersistedFolders(readPersistedFolders(body));
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
} catch (error) {
console.error(error);
}
};
const movePersistedFolder = async (
folderPath: string,
parentFolderPath: string | null,
folderStableId: string,
parentStableId: string | null,
targetIndex: number,
): Promise<void> => {
const projectId = selectedProject().id;
if (!folderPath || !folderStableId || !isUuidString(projectId)) {
return;
}
try {
const body = await moveProjectFolderRequest(projectId, {
folderId: folderPath,
folderNodeId: folderStableId,
parentFolderId: parentFolderPath,
parentNodeId: parentStableId,
targetIndex,
});
setPersistedFolders(readPersistedFolders(body));
} catch (error) {
console.error(error);
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = selectedProject().id;
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
const folderPath = resolveFolderPath(draft.folderId);
if (!folderPath) {
cancelPendingFolderRename();
return;
}
try {
const body = await renameProjectFolderRequest(projectId, folderPath, name);
setPersistedFolders(readPersistedFolders(body));
setPendingFolderRename(null);
setPendingFolderRenameName("");
} catch (error) {
console.error(error);
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleContextActionSelect = (action: { id: string; label: string }, target: ProjectMenuTarget): void => {
switch (action.id) {
case "new-folder":
switch (target.kind) {
case "surface":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1);
return;
case "project": {
const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null;
beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0);
return;
}
}
return;
case "delete-folder":
if (target.kind === "folder") {
void deletePersistedFolder(target.id);
}
return;
case "rename-folder":
if (target.kind === "folder") {
beginFolderRename(target.id, target.label, findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0);
}
return;
default:
return;
}
};
const handleSurfaceContextMenu = (event: MouseEvent): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectSurfaceTarget("Projects"));
};
const openRootCreateMenu = (element: HTMLElement): void => {
contextMenu.openMenuFromElement(element, createProjectSurfaceTarget("Projects"));
};
const treeControlLabel = (): string => (areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders");
const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
if (event.button !== 0 || pendingFolderDraft()) {
return;
}
updateDragGhostPosition(event.clientX, event.clientY);
clearLongPressTimer();
longPressTimer = window.setTimeout(() => {
suppressTreeClickTemporarily();
setDragState({ draggedNodeId: nodeId, dropTarget: null });
}, LONG_PRESS_MS);
};
const handleNodePointerMove = (event: PointerEvent, parentId: string | null, index: number, node: ProjectTreeNode): void => {
const nextDragState = dragState();
if (!nextDragState || nextDragState.draggedNodeId === (node.kind === "folder" ? node.id : node.item.id)) {
return;
}
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: projectTreeAdapter,
}),
});
};
const openFolderMenu = (event: MouseEvent, folder: ProjectFolderNode): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectFolderTarget(folder.id, folder.label));
};
const openProjectMenu = (event: MouseEvent, item: ProjectItem): void => {
event.stopPropagation();
contextMenu.openMenu(event, createProjectTarget(item));
};
const contextMenuPosition = () => {
const state = contextMenu.menuState();
return state ? { x: state.x, y: state.y } : null;
};
return {
selectedProject,
drawerTop,
setRootRef,
setTriggerRef,
setContextMenuElement,
toggleOpen,
handleSurfaceContextMenu,
openRootCreateMenu,
treeControlLabel,
toggleAllFolders,
totalFolderCount,
areAllFoldersCollapsed,
projectTreeNodes,
isFolderCollapsed,
toggleFolder,
selectProject,
openFolderMenu,
openProjectMenu,
handleNodePointerDown,
handleNodePointerMove,
pendingFolderDraft,
pendingFolderName,
setPendingFolderName,
submitPendingFolder,
cancelPendingFolder,
pendingFolderRename,
pendingFolderRenameName,
setPendingFolderRenameName,
submitPendingFolderRename,
cancelPendingFolderRename,
dragState,
suppressNextTreeClick,
contextMenu,
contextMenuPosition,
handleContextActionSelect,
draggedNode,
dragGhostPosition,
draggedNodeMeta,
};
};
@@ -0,0 +1,213 @@
// Path: Frontend/src/components/workspace-navigation/ProjectSelector/ProjectSelector.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { ChevronRight, Folder, LayoutGrid } from "../../../lib/icons";
import type { ProjectItem } from "../../app-shell/data/shell.data";
import {
getProjectTreeNodeId,
type PendingProjectFolderDraft,
type PendingProjectFolderRename,
type ProjectDragState,
type ProjectFolderBranchProps,
type ProjectFolderNode,
type ProjectTreeNode,
} from "./ProjectSelector.data";
import styles from "./ProjectSelector.module.scss";
export const ProjectFolderDraftRow = (props: {
depth: number;
value: string;
onInput: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
}): JSX.Element => {
let inputRef: HTMLInputElement | undefined;
queueMicrotask(() => inputRef?.focus());
return (
<li>
<div class={styles.treeInputRow} style={{ "--tree-depth": String(props.depth) }}>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<input
ref={inputRef}
type="text"
class={styles.treeInput}
value={props.value}
placeholder="Folder name"
onInput={(event): void => props.onInput(event.currentTarget.value)}
onBlur={props.onSubmit}
onKeyDown={(event): void => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === "Escape") {
event.preventDefault();
props.onCancel();
event.currentTarget.blur();
}
}}
/>
</div>
</li>
);
};
export const ProjectFolderBranch = (props: ProjectFolderBranchProps): JSX.Element => (
<ul class={styles.treeList} role="list">
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== props.parentId}>
<li>
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(props.depth) }} />
</li>
</Show>
<For each={props.nodes}>
{(node, indexAccessor): JSX.Element => {
const nodeId = (): string => getProjectTreeNodeId(node);
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === nodeId();
const dropIntent = (): "before" | "after" | "inside" | null => {
if (props.dragState?.dropTarget?.targetNodeId !== nodeId()) {
return null;
}
return props.dragState.dropTarget.intent;
};
if (node.kind === "folder") {
const isCollapsed = (): boolean => props.isFolderCollapsed(node.id);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
return (
<li>
<Show
when={isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
<Folder class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
}
>
<ProjectFolderDraftRow
depth={props.pendingFolderRename?.depth ?? props.depth}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
</Show>
<Show when={!isCollapsed() && ((node.children?.length ?? 0) > 0 || props.pendingFolderDraft?.parentId === node.id)}>
<ProjectFolderBranch
nodes={node.children}
depth={props.depth + 1}
parentId={node.id}
selectedProjectId={props.selectedProjectId}
isFolderCollapsed={props.isFolderCollapsed}
onToggleFolder={props.onToggleFolder}
onSelectProject={props.onSelectProject}
onOpenFolderMenu={props.onOpenFolderMenu}
onOpenProjectMenu={props.onOpenProjectMenu}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
pendingFolderDraft={props.pendingFolderDraft}
pendingFolderName={props.pendingFolderName}
onPendingFolderNameChange={props.onPendingFolderNameChange}
onSubmitPendingFolder={props.onSubmitPendingFolder}
onCancelPendingFolder={props.onCancelPendingFolder}
pendingFolderRename={props.pendingFolderRename}
pendingFolderRenameName={props.pendingFolderRenameName}
onPendingFolderRenameChange={props.onPendingFolderRenameChange}
onSubmitPendingFolderRename={props.onSubmitPendingFolderRename}
onCancelPendingFolderRename={props.onCancelPendingFolderRename}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
}
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: props.selectedProjectId === node.item.id,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
}}
style={{ "--tree-depth": String(props.depth) }}
onClick={(): void => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onSelectProject(node.item.id);
}}
onContextMenu={(event): void => props.onOpenProjectMenu(event, node.item)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.item.id)}
onPointerMove={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
onPointerEnter={(event): void => props.onNodePointerMove(event, props.parentId, indexAccessor(), node)}
>
<LayoutGrid class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.item.name}</span>
<Show when={node.item.meta}>
<span class={styles.itemMeta}>{node.item.meta}</span>
</Show>
</button>
</li>
);
}}
</For>
<Show when={props.pendingFolderDraft?.parentId === props.parentId}>
<ProjectFolderDraftRow
depth={props.pendingFolderDraft?.depth ?? props.depth}
value={props.pendingFolderName}
onInput={props.onPendingFolderNameChange}
onSubmit={props.onSubmitPendingFolder}
onCancel={props.onCancelPendingFolder}
/>
</Show>
</ul>
);

Some files were not shown because too many files have changed in this diff Show More