Compare commits

...

23 Commits

Author SHA1 Message Date
MangoPig dc081ccc5b Feat: add persisted account theme settings 2026-07-06 17:02:40 +01:00
MangoPig 715661a4d2 Feat: add route-backed settings pages 2026-07-06 16:59:50 +01:00
MangoPig d421a10189 Fix: export Go path for local auto migrations 2026-07-06 13:22:50 +01:00
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
MangoPig bcfe73411c Feat: improve sidebar tree usability 2026-06-28 19:04:48 +01:00
MangoPig 32acf6dc17 Merge branch 'Refactor/Usability' 2026-06-28 04:08:58 +01:00
MangoPig 6b87e8a1fe Refactor: improve bootstrap usability 2026-06-28 04:08:31 +01:00
MangoPig 3247f28c87 Merge branch 'Fix/Bootstrap' 2026-06-26 23:31:10 +01:00
MangoPig 891e8b83ed Fix: harden bootstrap worker flow 2026-06-26 23:30:50 +01:00
MangoPig ae1f347549 Merge branch 'Refactor/Code-Quality' 2026-06-26 18:33:12 +01:00
MangoPig 7e62ff6d9a Refactor: improve code quality and worker flow 2026-06-26 18:32:49 +01:00
MangoPig adcc9afe05 Merge branch 'Refactor/Folder-Layout' 2026-06-25 21:53:48 +01:00
MangoPig 24d1e472a2 Refactor: add stable folder layout foundation 2026-06-25 21:53:29 +01:00
MangoPig da1b210865 Merge branch 'Fix/Backend/Bootstrap-Personal-Folder' 2026-06-24 19:46:02 +01:00
MangoPig eadf630c61 Fix: scaffold bootstrap personal folder 2026-06-24 19:46:02 +01:00
MangoPig 4fb073a1ff Merge branch 'Fix/Frontend/Folder-Move-Persistence' 2026-06-24 18:00:05 +01:00
MangoPig 9ddfa0c3c7 Fix: persist folder drag and drop moves 2026-06-24 17:59:43 +01:00
MangoPig a92e188f84 Merge branch 'Fix/Frontend/Folder-Casing' 2026-06-24 17:24:59 +01:00
162 changed files with 14374 additions and 6325 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
+25 -3
View File
@@ -1,10 +1,16 @@
package main package main
import ( import (
"context"
"encoding/json"
"log" "log"
"os/signal"
"syscall"
"time"
"moku-backend/internal/bootstrap" "moku-backend/internal/bootstrap"
"moku-backend/internal/process" "moku-backend/internal/jobs"
"moku-backend/internal/worker"
) )
func main() { func main() {
@@ -18,9 +24,25 @@ func main() {
} }
}() }()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment) jobStore := jobs.NewStore(app.Database)
runner := worker.NewRunner(jobStore, app.Logger, time.Second)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
var payload jobs.BootstrapStructureMaterializePayload
if len(job.Payload) > 0 {
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return err
}
}
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil { return bootstrap.NewService(app.Database, app.Config.POSIXRoot).ProcessBootstrapStructureMaterialization(ctx, payload.InstallationID)
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment, "pollInterval", time.Second)
if err := runner.Run(ctx); err != nil {
app.Logger.Error("worker stopped", "error", err) app.Logger.Error("worker stopped", "error", err)
log.Fatal(err) log.Fatal(err)
} }
@@ -0,0 +1,64 @@
-- +goose Up
CREATE TYPE bootstrap_materialization_status AS ENUM ('not_started', 'pending', 'running', 'succeeded', 'failed');
CREATE TYPE background_job_status AS ENUM ('pending', 'running', 'succeeded', 'failed');
ALTER TABLE installations
ADD COLUMN IF NOT EXISTS materialization_status bootstrap_materialization_status NOT NULL DEFAULT 'not_started',
ADD COLUMN IF NOT EXISTS materialization_error TEXT,
ADD COLUMN IF NOT EXISTS materialization_requested_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_started_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS materialization_finished_at TIMESTAMPTZ;
UPDATE installations
SET
materialization_status = CASE
WHEN is_bootstrapped THEN 'succeeded'::bootstrap_materialization_status
ELSE 'not_started'::bootstrap_materialization_status
END,
materialization_error = NULL,
materialization_requested_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_started_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END,
materialization_finished_at = CASE
WHEN is_bootstrapped THEN COALESCE(bootstrapped_at, created_at, NOW())
ELSE NULL
END
WHERE materialization_status = 'not_started'::bootstrap_materialization_status;
CREATE TABLE IF NOT EXISTS background_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL,
status background_job_status NOT NULL DEFAULT 'pending',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 1,
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_background_jobs_claim ON background_jobs (status, available_at, created_at);
-- +goose Down
DROP INDEX IF EXISTS idx_background_jobs_claim;
DROP TABLE IF EXISTS background_jobs;
ALTER TABLE installations
DROP COLUMN IF EXISTS materialization_finished_at,
DROP COLUMN IF EXISTS materialization_started_at,
DROP COLUMN IF EXISTS materialization_requested_at,
DROP COLUMN IF EXISTS materialization_error,
DROP COLUMN IF EXISTS materialization_status;
DROP TYPE IF EXISTS background_job_status;
DROP TYPE IF EXISTS bootstrap_materialization_status;
+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,159 @@
package bootstrap
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
)
const (
materializationNotStarted = "not_started"
materializationPending = "pending"
materializationRunning = "running"
materializationSucceeded = "succeeded"
materializationFailed = "failed"
)
type bootstrapStructurePrerequisites struct {
installation InstallationRecord
admin AdminSummary
}
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &record, nil
}
func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
SELECT
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error
FROM installations
WHERE singleton = TRUE
LIMIT 1;
`))
}
func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) {
return scanInstallationRecord(tx.QueryRow(ctx, `
UPDATE installations
SET
is_bootstrapped = TRUE,
bootstrapped_at = COALESCE(bootstrapped_at, NOW()),
materialization_status = 'pending'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_requested_at = NOW(),
materialization_started_at = NULL,
materialization_finished_at = NULL,
updated_at = NOW()
WHERE singleton = TRUE
RETURNING
id::text,
name,
mode::text,
access::text,
protocol::text,
host,
is_bootstrapped,
materialization_status::text,
materialization_error;
`))
}
func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) {
var record InstallationRecord
if err := row.Scan(
&record.ID,
&record.Name,
&record.Mode,
&record.Access,
&record.Protocol,
&record.Host,
&record.IsBootstrapped,
&record.MaterializationStatus,
&record.MaterializationError,
); err != nil {
return InstallationRecord{}, err
}
if strings.TrimSpace(record.MaterializationStatus) == "" {
record.MaterializationStatus = materializationNotStarted
}
return record, nil
}
func loadPrimaryAdmin(ctx context.Context, tx pgx.Tx) (AdminSummary, error) {
var admin AdminSummary
if err := tx.QueryRow(ctx, `
SELECT id::text, email, display_name
FROM users
WHERE is_instance_admin = TRUE
ORDER BY created_at ASC
LIMIT 1;
`).Scan(&admin.ID, &admin.Email, &admin.DisplayName); err != nil {
return AdminSummary{}, err
}
return admin, nil
}
func (service *Service) loadBootstrapStructurePrerequisites(
ctx context.Context,
tx pgx.Tx,
) (bootstrapStructurePrerequisites, error) {
installation, err := loadInstallation(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrInstallationNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
admin, err := loadPrimaryAdmin(ctx, tx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return bootstrapStructurePrerequisites{}, ErrAdminNotConfigured
}
return bootstrapStructurePrerequisites{}, err
}
return bootstrapStructurePrerequisites{
installation: installation,
admin: admin,
}, nil
}
@@ -0,0 +1,180 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"strings"
"moku-backend/internal/jobs"
"moku-backend/internal/posixproj"
)
func (service *Service) enqueueBootstrapStructureMaterialization(
ctx context.Context,
installation *InstallationRecord,
) error {
if installation == nil {
return nil
}
jobStore := jobs.NewStore(service.db)
if _, err := jobStore.Enqueue(ctx, jobs.EnqueueInput{
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: jobs.BootstrapStructureMaterializePayload{
InstallationID: installation.ID,
},
}); err != nil {
failure := fmt.Sprintf("enqueue bootstrap materialization job: %v", err)
if markErr := service.markBootstrapMaterializationFailed(ctx, installation.ID, failure); markErr != nil {
return errors.Join(err, markErr)
}
// The relational bootstrap write already committed successfully, so keep the
// response successful and surface the enqueue problem via materialization state.
installation.MaterializationStatus = materializationFailed
installation.MaterializationError = &failure
}
return nil
}
func (service *Service) ProcessBootstrapStructureMaterialization(ctx context.Context, installationID string) error {
trimmedInstallationID := strings.TrimSpace(installationID)
if trimmedInstallationID == "" {
return ErrInstallationNotConfigured
}
installation, err := service.GetInstallation(ctx)
if err != nil {
return err
}
if installation == nil || installation.ID != trimmedInstallationID {
return ErrInstallationNotConfigured
}
if err := service.markBootstrapMaterializationRunning(ctx, trimmedInstallationID); err != nil {
return err
}
if err := service.materializeBootstrapStructure(ctx, *installation); err != nil {
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "bootstrap materialization failed"
}
if markErr := service.markBootstrapMaterializationFailed(ctx, trimmedInstallationID, failure); markErr != nil {
return errors.Join(err, markErr)
}
return err
}
return service.markBootstrapMaterializationSucceeded(ctx, trimmedInstallationID)
}
func (service *Service) materializeBootstrapStructure(ctx context.Context, installation InstallationRecord) error {
admin, err := service.GetAdmin(ctx)
if err != nil {
return err
}
if admin == nil {
return ErrAdminNotConfigured
}
organization, err := service.loadPrimaryOrganization(ctx)
if err != nil {
return err
}
if organization == nil {
return ErrBootstrapStructureMissing
}
department, err := service.loadPrimaryDepartment(ctx)
if err != nil {
return err
}
if department == nil {
return ErrBootstrapStructureMissing
}
team, err := service.loadPrimaryTeam(ctx)
if err != nil {
return err
}
if team == nil {
return ErrBootstrapStructureMissing
}
project, err := service.loadPrimaryProject(ctx)
if err != nil {
return err
}
if project == nil {
return ErrBootstrapStructureMissing
}
if err := service.ensureBootstrapPOSIXSkeleton(
installation,
AdminSummary{ID: admin.ID, Email: admin.Email, DisplayName: admin.DisplayName},
namedRecord{ID: organization.ID, Name: organization.Name, Slug: organization.Slug},
namedRecord{ID: department.ID, Name: department.Name, Slug: department.Slug},
namedRecord{ID: team.ID, Name: team.Name, Slug: team.Slug},
namedRecord{ID: project.ID, Name: project.Name, Slug: project.Slug},
); err != nil {
return err
}
return service.rebuildProjection(ctx)
}
func (service *Service) rebuildProjection(ctx context.Context) error {
if err := posixproj.NewProjector(service.db, service.posixRoot).Rebuild(ctx); err != nil {
return fmt.Errorf("rebuild POSIX projection: %w", err)
}
return nil
}
func (service *Service) markBootstrapMaterializationRunning(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'running'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_started_at = NOW(),
materialization_finished_at = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationSucceeded(ctx context.Context, installationID string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'succeeded'::bootstrap_materialization_status,
materialization_error = NULL,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID))
return err
}
func (service *Service) markBootstrapMaterializationFailed(ctx context.Context, installationID, failure string) error {
_, err := service.db.Pool.Exec(ctx, `
UPDATE installations
SET
materialization_status = 'failed'::bootstrap_materialization_status,
materialization_error = $2,
materialization_finished_at = NOW(),
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(installationID), strings.TrimSpace(failure))
return err
}
@@ -0,0 +1,73 @@
package bootstrap
import "context"
func (service *Service) GetState(ctx context.Context) (BootstrapState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return BootstrapState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return BootstrapState{}, err
}
structure, err := service.GetStructure(ctx)
if err != nil {
return BootstrapState{}, err
}
return BootstrapState{
Installation: installation,
Admin: admin,
Structure: structure,
}, nil
}
func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, error) {
installation, err := service.GetInstallation(ctx)
if err != nil {
return AppShellState{}, err
}
admin, err := service.GetAdmin(ctx)
if err != nil {
return AppShellState{}, err
}
organizations, err := service.listOrganizations(ctx)
if err != nil {
return AppShellState{}, err
}
departments, err := service.listDepartments(ctx)
if err != nil {
return AppShellState{}, err
}
teams, err := service.listTeams(ctx)
if err != nil {
return AppShellState{}, err
}
projects, err := service.listProjects(ctx)
if err != nil {
return AppShellState{}, err
}
workspaces, err := service.listWorkspaces(ctx)
if err != nil {
return AppShellState{}, err
}
return AppShellState{
Installation: installation,
Admin: admin,
Organizations: organizations,
Departments: departments,
Teams: teams,
Projects: projects,
Workspaces: workspaces,
}, nil
}
@@ -0,0 +1,314 @@
// 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"`
ThemePresetID string `json:"themePresetId,omitempty"`
}
type SaveThemePresetInput struct {
PresetID string
}
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,260 @@
// 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,
"theme": map[string]any{
"presetId": "moku-midnight",
},
}); 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
+420 -31
View File
@@ -2,11 +2,76 @@ package bootstrap
import ( import (
"encoding/json" "encoding/json"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/fxamacker/cbor/v2"
"github.com/tailscale/hujson"
) )
type fakeRow struct {
scan func(dest ...any) error
}
func (row fakeRow) Scan(dest ...any) error {
return row.scan(dest...)
}
func TestScanInstallationRecordDefaultsMaterializationStatus(t *testing.T) {
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-1"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = ""
*(dest[8].(**string)) = nil
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationNotStarted {
t.Fatalf("expected default materialization status %q, got %q", materializationNotStarted, record.MaterializationStatus)
}
if record.MaterializationError != nil {
t.Fatalf("expected nil materialization error, got %#v", record.MaterializationError)
}
}
func TestScanInstallationRecordPreservesMaterializationFields(t *testing.T) {
failure := "projection rebuild failed"
record, err := scanInstallationRecord(fakeRow{scan: func(dest ...any) error {
*(dest[0].(*string)) = "installation-2"
*(dest[1].(*string)) = "MangoPig"
*(dest[2].(*string)) = "personal"
*(dest[3].(*string)) = "local"
*(dest[4].(*string)) = "http"
*(dest[5].(*string)) = "localhost"
*(dest[6].(*bool)) = true
*(dest[7].(*string)) = materializationFailed
*(dest[8].(**string)) = &failure
return nil
}})
if err != nil {
t.Fatalf("scanInstallationRecord: %v", err)
}
if record.MaterializationStatus != materializationFailed {
t.Fatalf("expected materialization status %q, got %q", materializationFailed, record.MaterializationStatus)
}
if record.MaterializationError == nil || *record.MaterializationError != failure {
t.Fatalf("expected materialization error %q, got %#v", failure, record.MaterializationError)
}
}
func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) { func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX") rootPath := filepath.Join(t.TempDir(), "POSIX")
t.Setenv("POSIX_ROOT", rootPath) t.Setenv("POSIX_ROOT", rootPath)
@@ -42,22 +107,29 @@ 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", posixSettingsFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", posixLayoutFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "tree"),
} }
for _, path := range requiredPaths { for _, path := range requiredPaths {
@@ -66,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"])
@@ -78,29 +150,77 @@ 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 := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixSettingsFileName))
if personalSettings["type"] != "personal" {
t.Fatalf("expected personal settings type personal, got %#v", personalSettings["type"])
}
if personalSettings["name"] != "Ronald" {
t.Fatalf("expected personal name Ronald, got %#v", personalSettings["name"])
}
if personalSettings["slug"] != "ronald" {
t.Fatalf("expected personal slug ronald, got %#v", personalSettings["slug"])
}
personalTheme, ok := personalSettings["theme"].(map[string]any)
if !ok {
t.Fatalf("expected personal settings to contain theme object, got %#v", personalSettings)
}
if personalTheme["presetId"] != defaultThemePresetID {
t.Fatalf("expected personal theme preset %s, got %#v", defaultThemePresetID, personalTheme["presetId"])
}
personalHome := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", posixHomeFileName))
if personalHome["type"] != "personal-home" {
t.Fatalf("expected personal home type personal-home, got %#v", personalHome["type"])
}
if personalHome["title"] != "Ronald's Home" {
t.Fatalf("expected personal home title Ronald's Home, got %#v", personalHome["title"])
}
} }
func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing.T) { func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
@@ -132,8 +252,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 {
@@ -141,7 +261,10 @@ 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"])) == "" {
t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Design System" { if folderPayload["name"] != "Design System" {
t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"]) t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"])
} }
@@ -161,6 +284,39 @@ func TestCreateProjectHierarchyFolderOnDiskCreatesExpectedFolderShape(t *testing
} }
} }
func TestPersonalSettingsPathForDisplayName(t *testing.T) {
path := personalSettingsPathForDisplayName(" Ronald ")
if path != "users/personals/personal-ronald/settings.cbor" {
t.Fatalf("unexpected personal settings path: %s", path)
}
}
func TestApplyThemePresetToSettingsPreservesExistingFields(t *testing.T) {
settings := map[string]any{
"type": "personal",
"slug": "ronald",
"theme": map[string]any{
"density": "comfortable",
},
}
updated := applyThemePresetToSettings(settings, "moku-default")
if updated["type"] != "personal" {
t.Fatalf("expected type personal, got %#v", updated["type"])
}
theme, ok := updated["theme"].(map[string]any)
if !ok {
t.Fatalf("expected theme object, got %#v", updated["theme"])
}
if theme["presetId"] != "moku-default" {
t.Fatalf("expected presetId moku-default, got %#v", theme["presetId"])
}
if theme["density"] != "comfortable" {
t.Fatalf("expected density to be preserved, got %#v", theme["density"])
}
}
func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) { func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX") rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath) service := NewService(nil, rootPath)
@@ -190,8 +346,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 {
@@ -257,7 +413,10 @@ 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"])) == "" {
t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Platform Design" { if folderPayload["name"] != "Platform Design" {
t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"]) t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"])
} }
@@ -301,7 +460,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"])
} }
@@ -310,11 +469,155 @@ func TestRenameProjectTreeFolderOnDiskRenamesFolderShape(t *testing.T) {
} }
} }
func TestMoveProjectHierarchyFolderOnDiskMovesFolderToNewParent(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
designPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design")
if err != nil {
t.Fatalf("create design folder: %v", err)
}
operationsPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Operations")
if err != nil {
t.Fatalf("create operations folder: %v", err)
}
researchPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", designPath, "Research")
if err != nil {
t.Fatalf("create research folder: %v", err)
}
nestedPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", researchPath, "Interview Notes")
if err != nil {
t.Fatalf("create nested folder: %v", err)
}
previousPath, movedPath, err := service.moveProjectHierarchyFolderOnDisk("primary-project", researchPath, operationsPath)
if err != nil {
t.Fatalf("moveProjectHierarchyFolderOnDisk: %v", err)
}
if previousPath != researchPath {
t.Fatalf("expected previous path %s, got %s", researchPath, previousPath)
}
if movedPath != "projects/project-primary-project/children/folder-operations/children/folder-research" {
t.Fatalf("unexpected moved path: %s", movedPath)
}
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(researchPath))); !os.IsNotExist(err) {
t.Fatalf("expected previous folder path to be gone, got err=%v", err)
}
movedFolderPath := filepath.Join(rootPath, filepath.FromSlash(movedPath))
if _, err := os.Stat(filepath.Join(movedFolderPath, "children", filepath.Base(nestedPath))); err != nil {
t.Fatalf("expected nested child folder to move with moved parent: %v", err)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(movedFolderPath, posixFolderFileName))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected moved folder to preserve stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Research" {
t.Fatalf("expected moved folder name Research, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "research" {
t.Fatalf("expected moved folder slug research, got %#v", folderPayload["slug"])
}
if folderPayload["type"] != "folder" {
t.Fatalf("expected moved folder type folder, got %#v", folderPayload["type"])
}
}
func TestMoveProjectTreeFolderOnDiskMovesFolderToNewParent(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
docsPath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("create docs folder: %v", err)
}
archivePath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Archive")
if err != nil {
t.Fatalf("create archive folder: %v", err)
}
previousPath, movedPath, err := service.moveProjectTreeFolderOnDisk("primary-project", docsPath, archivePath)
if err != nil {
t.Fatalf("moveProjectTreeFolderOnDisk: %v", err)
}
if previousPath != docsPath {
t.Fatalf("expected previous path %s, got %s", docsPath, previousPath)
}
if movedPath != "projects/project-primary-project/tree/folder-archive/children/folder-docs" {
t.Fatalf("unexpected moved path: %s", movedPath)
}
folderPayload := readStructuredFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(movedPath), posixFolderFileName))
if folderPayload["name"] != "Docs" {
t.Fatalf("expected moved folder name Docs, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "docs" {
t.Fatalf("expected moved folder slug docs, got %#v", folderPayload["slug"])
}
}
func TestMoveProjectHierarchyFolderOnDiskRejectsDescendantTarget(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
service := NewService(nil, rootPath)
err := service.ensureBootstrapPOSIXSkeleton(
InstallationRecord{ID: "installation-1", Name: "MangoPig", Mode: "personal", Access: "local", Protocol: "http", Host: "localhost", IsBootstrapped: true},
AdminSummary{ID: "admin-1", Email: "ronald@example.com", DisplayName: "Ronald"},
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
)
if err != nil {
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
}
parentPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Parent")
if err != nil {
t.Fatalf("create parent folder: %v", err)
}
childPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", parentPath, "Child")
if err != nil {
t.Fatalf("create child folder: %v", err)
}
_, _, err = service.moveProjectHierarchyFolderOnDisk("primary-project", parentPath, childPath)
if !errors.Is(err, ErrInvalidProjectFolderMove) {
t.Fatalf("expected ErrInvalidProjectFolderMove, got %v", err)
}
}
func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) { func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
rows := []projectHierarchyFolderRow{ rows := []projectHierarchyFolderRow{
{Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"}, {ID: "folder-design-id", Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
{Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"}, {ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", ParentPath: "projects/project-primary-project/children/folder-design/children", Label: "Research"},
{Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"}, {ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"},
} }
folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project")) folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project"))
@@ -327,9 +630,67 @@ func TestBuildProjectHierarchyFolderTreeBuildsNestedStructure(t *testing.T) {
if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" { if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" {
t.Fatalf("unexpected nested folder structure: %#v", folders[0].Children) t.Fatalf("unexpected nested folder structure: %#v", folders[0].Children)
} }
if folders[0].ID != "folder-design-id" || folders[0].Path != "projects/project-primary-project/children/folder-design" {
t.Fatalf("expected design folder to retain stable id/path, got %#v", folders[0])
}
if folders[0].Children[0].ID != "folder-research-id" || folders[1].ID != "folder-ops-id" {
t.Fatalf("expected nested/top-level folder ids to be preserved, got %#v / %#v", folders[0].Children[0], folders[1])
}
} }
func readJSONFileForTest[T any](t *testing.T, path string) T { func TestApplyProjectHierarchyFolderOrderingOrdersRootAndChildrenByStableID(t *testing.T) {
folders := []ProjectHierarchyFolderRecord{
{
ID: "folder-design-id",
Path: "projects/project-primary-project/children/folder-design",
Label: "Design",
Children: []ProjectHierarchyFolderRecord{
{ID: "folder-research-id", Path: "projects/project-primary-project/children/folder-design/children/folder-research", Label: "Research"},
{ID: "folder-assets-id", Path: "projects/project-primary-project/children/folder-design/children/folder-assets", Label: "Assets"},
},
},
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", Label: "Ops"},
{ID: "folder-qa-id", Path: "projects/project-primary-project/children/folder-qa", Label: "QA"},
}
ordered := applyProjectHierarchyFolderOrdering(folders, map[string][]string{
projectFolderOrderRootKey: {"folder-qa-id", "folder-design-id"},
"folder-design-id": {"folder-assets-id", "folder-research-id"},
})
if len(ordered) != 3 {
t.Fatalf("expected 3 ordered root folders, got %d", len(ordered))
}
if ordered[0].ID != "folder-qa-id" || ordered[1].ID != "folder-design-id" || ordered[2].ID != "folder-ops-id" {
t.Fatalf("unexpected ordered root ids: %#v", ordered)
}
if len(ordered[1].Children) != 2 {
t.Fatalf("expected design folder children to be preserved, got %#v", ordered[1].Children)
}
if ordered[1].Children[0].ID != "folder-assets-id" || ordered[1].Children[1].ID != "folder-research-id" {
t.Fatalf("unexpected ordered child ids: %#v", ordered[1].Children)
}
}
func TestInsertFolderOrderReordersWithinSameParent(t *testing.T) {
folderOrder := map[string][]string{
projectFolderOrderRootKey: {"folder-a", "folder-b", "folder-c"},
}
insertFolderOrder(folderOrder, "", "folder-c", 0)
got := folderOrder[projectFolderOrderRootKey]
if len(got) != 3 || got[0] != "folder-c" || got[1] != "folder-a" || got[2] != "folder-b" {
t.Fatalf("unexpected reordered root children: %#v", got)
}
}
func asStringForTest(value any) string {
text, _ := value.(string)
return text
}
func readStructuredFileForTest[T any](t *testing.T, path string) T {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -337,9 +698,37 @@ 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 raw any
switch strings.ToLower(filepath.Ext(path)) {
case ".cbor":
if err := cbor.Unmarshal(data, &raw); err != nil {
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 var payload T
if err := json.Unmarshal(data, &payload); err != nil { if err := json.Unmarshal(normalizedBytes, &payload); err != nil {
t.Fatalf("unmarshal %s: %v", path, err) t.Fatalf("decode normalized %s: %v", path, err)
} }
return payload return payload
@@ -0,0 +1,111 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5"
)
const defaultThemePresetID = "moku-midnight"
func personalSettingsPathForDisplayName(displayName string) string {
personalName := strings.TrimSpace(displayName)
if personalName == "" {
personalName = defaultPersonalDisplayName
}
personalSlug := normalizePOSIXSlug(personalName)
return filepath.ToSlash(filepath.Join("users", "personals", slugDir("personal", personalSlug), posixSettingsFileName))
}
func extractThemePresetID(settings map[string]any) string {
theme, ok := settings["theme"].(map[string]any)
if !ok {
return ""
}
presetID, _ := theme["presetId"].(string)
return strings.TrimSpace(presetID)
}
func applyThemePresetToSettings(settings map[string]any, presetID string) map[string]any {
normalized := settings
if normalized == nil {
normalized = map[string]any{}
}
theme, ok := normalized["theme"].(map[string]any)
if !ok || theme == nil {
theme = map[string]any{}
}
theme["presetId"] = presetID
normalized["theme"] = theme
return normalized
}
func (service *Service) SaveThemePreset(ctx context.Context, input SaveThemePresetInput) (*AdminRecord, error) {
admin, err := service.GetAdmin(ctx)
if err != nil {
return nil, err
}
if admin == nil {
return nil, ErrAdminNotConfigured
}
presetID := strings.TrimSpace(input.PresetID)
if presetID == "" {
return nil, fmt.Errorf("theme preset id is required")
}
rootPath := strings.TrimSpace(service.posixRoot)
if rootPath == "" {
return nil, fmt.Errorf("posix root is not configured")
}
relativeSettingsPath := personalSettingsPathForDisplayName(admin.DisplayName)
absoluteSettingsPath := filepath.Join(rootPath, filepath.FromSlash(relativeSettingsPath))
settings := readStructuredFileMap(absoluteSettingsPath)
if len(settings) == 0 {
return nil, fmt.Errorf("personal settings file is missing")
}
updatedSettings := applyThemePresetToSettings(settings, presetID)
if err := writeCBORFile(absoluteSettingsPath, updatedSettings); err != nil {
return nil, fmt.Errorf("write personal %s: %w", posixSettingsFileName, err)
}
if err := service.rebuildProjection(ctx); err != nil {
return nil, err
}
return service.GetAdmin(ctx)
}
func (service *Service) loadAdminThemePresetID(ctx context.Context, displayName string) (string, error) {
path := personalSettingsPathForDisplayName(displayName)
var presetID *string
err := service.db.Pool.QueryRow(ctx, `
SELECT content_json->'theme'->>'presetId'
FROM posix_nodes
WHERE path = $1
LIMIT 1;
`, path).Scan(&presetID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", err
}
if presetID == nil {
return "", nil
}
return strings.TrimSpace(*presetID), nil
}
@@ -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)
}
}
+236 -91
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,20 +11,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type createProjectFolderRequest struct {
Name string `json:"name"`
ParentFolderID string `json:"parentFolderId"`
}
type renameProjectFolderRequest struct {
FolderID string `json:"folderId"`
Name string `json:"name"`
}
type deleteProjectFolderRequest struct {
FolderID string `json:"folderId"`
}
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 == "" {
@@ -63,16 +48,16 @@ func (routes apiRoutes) handleCreateProjectFolder(w http.ResponseWriter, r *http
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
} }
result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "persist") routes.writeProjectFolderError(w, r, err, "persist")
@@ -96,14 +81,14 @@ func (routes apiRoutes) handleDeleteProjectFolder(w http.ResponseWriter, r *http
} }
payload := decodeDeleteProjectFolderRequest(r) payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" { if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -131,9 +116,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -143,9 +128,9 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
} }
result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "rename") routes.writeProjectFolderError(w, r, err, "rename")
@@ -161,6 +146,49 @@ func (routes apiRoutes) handleRenameProjectFolder(w http.ResponseWriter, r *http
}) })
} }
func (routes apiRoutes) handleMoveProjectFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
payload, ok := decodeMoveProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
FolderStableID: payload.FolderStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-move",
"persisted": true,
},
})
}
func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" { if projectID == "" {
@@ -185,6 +213,30 @@ func (routes apiRoutes) handleProjectTreeFolders(w http.ResponseWriter, r *http.
}) })
} }
func (routes apiRoutes) handleProjectTree(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
nodes, err := routes.bootstrapService().GetProjectTreeNodes(r.Context(), projectID)
if err != nil {
routes.writeProjectTreeError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"nodes": nodes,
},
"meta": map[string]any{
"resource": "project-tree",
},
})
}
func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) { func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId")) projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" { if projectID == "" {
@@ -198,16 +250,16 @@ func (routes apiRoutes) handleCreateProjectTreeFolder(w http.ResponseWriter, r *
} }
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID) payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
if payload.Name == "" { if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return return
} }
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{ result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
ParentFolderID: payload.ParentFolderID, ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "persist") routes.writeProjectFolderError(w, r, err, "persist")
@@ -231,14 +283,14 @@ func (routes apiRoutes) handleDeleteProjectTreeFolder(w http.ResponseWriter, r *
} }
payload := decodeDeleteProjectFolderRequest(r) payload := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" { if strings.TrimSpace(payload.FolderPath) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{ result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "delete") routes.writeProjectFolderError(w, r, err, "delete")
@@ -266,9 +318,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
return return
} }
payload.FolderID = strings.TrimSpace(payload.FolderID) payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.Name = strings.TrimSpace(payload.Name) payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" { if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.") WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return return
} }
@@ -278,9 +330,9 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
} }
result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{ result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID, ProjectID: projectID,
FolderID: payload.FolderID, FolderPath: payload.FolderPath,
Name: payload.Name, Name: payload.Name,
}) })
if err != nil { if err != nil {
routes.writeProjectFolderError(w, r, err, "rename") routes.writeProjectFolderError(w, r, err, "rename")
@@ -296,70 +348,163 @@ func (routes apiRoutes) handleRenameProjectTreeFolder(w http.ResponseWriter, r *
}) })
} }
func (routes apiRoutes) writeProjectFolderError(w http.ResponseWriter, r *http.Request, err error, operation string) { func (routes apiRoutes) handleMoveProjectTreeFolder(w http.ResponseWriter, r *http.Request) {
switch { projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
case errors.Is(err, bootstrapservice.ErrProjectNotFound), errors.Is(err, bootstrapservice.ErrProjectFolderNotFound): if projectID == "" {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", err.Error()) WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
default: return
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)
} }
payload, ok := decodeMoveProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderPath = strings.TrimSpace(payload.FolderPath)
payload.FolderStableID = strings.TrimSpace(payload.FolderStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.FolderPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectTreeFolder(r.Context(), bootstrapservice.MoveProjectFolderInput{
ProjectID: projectID,
FolderPath: payload.FolderPath,
FolderStableID: payload.FolderStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-move",
"persisted": true,
},
})
} }
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest { func (routes apiRoutes) handleCreateProjectTreeItem(w http.ResponseWriter, r *http.Request) {
return deleteProjectFolderRequest{ projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")), if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
} }
payload, ok := decodeProjectItemRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ItemType = strings.TrimSpace(payload.ItemType)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item name is required.")
return
}
if payload.ItemType == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item type is required.")
return
}
result, err := routes.bootstrapService().CreateProjectTreeItem(r.Context(), bootstrapservice.CreateProjectItemInput{
ProjectID: projectID,
ParentFolderPath: payload.ParentFolderPath,
Name: payload.Name,
ItemType: payload.ItemType,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-create",
"persisted": true,
},
})
} }
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) { func (routes apiRoutes) handleDeleteProjectTreeItem(w http.ResponseWriter, r *http.Request) {
var payload renameProjectFolderRequest projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
decoder := json.NewDecoder(r.Body) WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
decoder.DisallowUnknownFields() return
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) { payload := decodeDeleteProjectItemRequest(r)
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") if strings.TrimSpace(payload.ItemPath) == "" {
return payload, false WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
} }
return payload, true result, err := routes.bootstrapService().DeleteProjectTreeItem(r.Context(), bootstrapservice.DeleteProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-delete",
"persisted": true,
},
})
} }
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) { func (routes apiRoutes) handleMoveProjectTreeItem(w http.ResponseWriter, r *http.Request) {
var payload createProjectFolderRequest projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
decoder := json.NewDecoder(r.Body) WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
decoder.DisallowUnknownFields() return
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) { payload, ok := decodeMoveProjectItemRequest(w, r)
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.") if !ok {
return payload, false return
} }
return payload, true payload.ItemPath = strings.TrimSpace(payload.ItemPath)
payload.ItemStableID = strings.TrimSpace(payload.ItemStableID)
payload.ParentFolderPath = strings.TrimSpace(payload.ParentFolderPath)
payload.ParentStableID = strings.TrimSpace(payload.ParentStableID)
if payload.ItemPath == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Item ID is required.")
return
}
result, err := routes.bootstrapService().MoveProjectTreeItem(r.Context(), bootstrapservice.MoveProjectItemInput{
ProjectID: projectID,
ItemPath: payload.ItemPath,
ItemStableID: payload.ItemStableID,
ParentFolderPath: payload.ParentFolderPath,
ParentStableID: payload.ParentStableID,
TargetIndex: payload.TargetIndex,
})
if err != nil {
routes.writeProjectTreeError(w, r, err, "move")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-item-move",
"persisted": true,
},
})
} }
+7
View File
@@ -31,17 +31,24 @@ func (routes apiRoutes) Register(router chi.Router) {
bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep) bootstrapRouter.Post("/structure", routes.handleBootstrapStructureStep)
}) })
apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/app-shell", routes.handleAppShellState)
apiRouter.Put("/settings/theme", routes.handleSaveThemePreset)
apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces) apiRouter.Get("/workspaces", routes.handleWorkspaces)
apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) { apiRouter.Route("/projects/{projectId}", func(projectRouter chi.Router) {
projectRouter.Get("/folders", routes.handleProjectFolders) projectRouter.Get("/folders", routes.handleProjectFolders)
projectRouter.Post("/folders", routes.handleCreateProjectFolder) projectRouter.Post("/folders", routes.handleCreateProjectFolder)
projectRouter.Patch("/folders", routes.handleRenameProjectFolder) projectRouter.Patch("/folders", routes.handleRenameProjectFolder)
projectRouter.Patch("/folders/move", routes.handleMoveProjectFolder)
projectRouter.Delete("/folders", routes.handleDeleteProjectFolder) projectRouter.Delete("/folders", routes.handleDeleteProjectFolder)
projectRouter.Get("/tree", routes.handleProjectTree)
projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders) projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders)
projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder) projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder)
projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder) projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder)
projectRouter.Patch("/tree/folders/move", routes.handleMoveProjectTreeFolder)
projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder) projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder)
projectRouter.Post("/tree/items", routes.handleCreateProjectTreeItem)
projectRouter.Patch("/tree/items/move", routes.handleMoveProjectTreeItem)
projectRouter.Delete("/tree/items", routes.handleDeleteProjectTreeItem)
}) })
if routes.cfg.Config.IsDevelopment() { if routes.cfg.Config.IsDevelopment() {
@@ -0,0 +1,43 @@
package httpx
import (
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
)
type saveThemePresetRequest struct {
PresetID string `json:"presetId"`
}
func (routes apiRoutes) handleSaveThemePreset(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[saveThemePresetRequest](w, r)
if !ok {
return
}
payload.PresetID = strings.TrimSpace(payload.PresetID)
if payload.PresetID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Theme preset ID is required.")
return
}
admin, err := routes.bootstrapService().SaveThemePreset(r.Context(), bootstrapservice.SaveThemePresetInput{
PresetID: payload.PresetID,
})
if err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"presetId": payload.PresetID,
"admin": admin,
},
"meta": map[string]any{
"resource": "settings-theme",
},
})
}
+198
View File
@@ -0,0 +1,198 @@
// Path: Backend/internal/jobs/store.go
package jobs
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/jackc/pgx/v5"
"moku-backend/internal/database"
)
const (
KindBootstrapStructureMaterialize = "bootstrap.structure.materialize"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusSucceeded Status = "succeeded"
StatusFailed Status = "failed"
)
type BootstrapStructureMaterializePayload struct {
InstallationID string `json:"installationId"`
}
type Job struct {
ID string
Kind string
Status Status
Payload json.RawMessage
Attempts int
MaxAttempts int
AvailableAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
LastError *string
CreatedAt time.Time
UpdatedAt time.Time
}
type EnqueueInput struct {
Kind string
Payload any
AvailableAt time.Time
MaxAttempts int
}
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (store *Store) Enqueue(ctx context.Context, input EnqueueInput) (Job, error) {
payload := json.RawMessage([]byte(`{}`))
if input.Payload != nil {
encoded, err := json.Marshal(input.Payload)
if err != nil {
return Job{}, err
}
payload = encoded
}
availableAt := input.AvailableAt
if availableAt.IsZero() {
availableAt = time.Now().UTC()
}
maxAttempts := input.MaxAttempts
if maxAttempts < 1 {
maxAttempts = 1
}
return scanJob(store.db.Pool.QueryRow(ctx, `
INSERT INTO background_jobs (kind, status, payload, attempts, max_attempts, available_at)
VALUES ($1, 'pending'::background_job_status, $2::jsonb, 0, $3, $4)
RETURNING
id::text,
kind,
status::text,
payload,
attempts,
max_attempts,
available_at,
started_at,
finished_at,
last_error,
created_at,
updated_at;
`, strings.TrimSpace(input.Kind), payload, maxAttempts, availableAt))
}
func (store *Store) ClaimNext(ctx context.Context) (*Job, error) {
job, err := scanJob(store.db.Pool.QueryRow(ctx, `
WITH next_job AS (
SELECT id
FROM background_jobs
WHERE status = 'pending'::background_job_status
AND available_at <= NOW()
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE background_jobs AS jobs
SET
status = 'running'::background_job_status,
attempts = jobs.attempts + 1,
started_at = NOW(),
finished_at = NULL,
last_error = NULL,
updated_at = NOW()
FROM next_job
WHERE jobs.id = next_job.id
RETURNING
jobs.id::text,
jobs.kind,
jobs.status::text,
jobs.payload,
jobs.attempts,
jobs.max_attempts,
jobs.available_at,
jobs.started_at,
jobs.finished_at,
jobs.last_error,
jobs.created_at,
jobs.updated_at;
`))
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
return &job, nil
}
func (store *Store) MarkSucceeded(ctx context.Context, jobID string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'succeeded'::background_job_status,
finished_at = NOW(),
last_error = NULL,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID))
return err
}
func (store *Store) MarkFailed(ctx context.Context, jobID, failure string) error {
_, err := store.db.Pool.Exec(ctx, `
UPDATE background_jobs
SET
status = 'failed'::background_job_status,
finished_at = NOW(),
last_error = $2,
updated_at = NOW()
WHERE id = $1::uuid;
`, strings.TrimSpace(jobID), strings.TrimSpace(failure))
return err
}
func scanJob(row pgx.Row) (Job, error) {
var job Job
var status string
if err := row.Scan(
&job.ID,
&job.Kind,
&status,
&job.Payload,
&job.Attempts,
&job.MaxAttempts,
&job.AvailableAt,
&job.StartedAt,
&job.FinishedAt,
&job.LastError,
&job.CreatedAt,
&job.UpdatedAt,
); err != nil {
return Job{}, err
}
job.Status = Status(status)
return job, nil
}
+5 -454
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)}
} }
@@ -145,9 +88,9 @@ func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSumm
node.TeamSlug, node.TeamSlug,
node.ProjectSlug, node.ProjectSlug,
node.PersonalSlug, node.PersonalSlug,
node.ContentJSON, node.ContentJSON,
node.SizeBytes, node.SizeBytes,
node.Checksum, node.Checksum,
); err != nil { ); err != nil {
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err) return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
} }
@@ -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')
}
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)
} }
bytes = append(bytes, '\n')
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
}
+126
View File
@@ -0,0 +1,126 @@
// Path: Backend/internal/worker/runner.go
package worker
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"moku-backend/internal/jobs"
)
type JobStore interface {
ClaimNext(ctx context.Context) (*jobs.Job, error)
MarkSucceeded(ctx context.Context, jobID string) error
MarkFailed(ctx context.Context, jobID, failure string) error
}
type Handler func(ctx context.Context, job jobs.Job) error
type Runner struct {
store JobStore
logger *slog.Logger
pollInterval time.Duration
handlers map[string]Handler
}
func NewRunner(store JobStore, logger *slog.Logger, pollInterval time.Duration) *Runner {
interval := pollInterval
if interval <= 0 {
interval = time.Second
}
return &Runner{
store: store,
logger: logger,
pollInterval: interval,
handlers: make(map[string]Handler),
}
}
func (runner *Runner) Register(kind string, handler Handler) {
runner.handlers[strings.TrimSpace(kind)] = handler
}
func (runner *Runner) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
job, err := runner.store.ClaimNext(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
runner.logger.Error("worker claim failed", "error", err)
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
if job == nil {
if err := waitForNextPoll(ctx, runner.pollInterval); err != nil {
return nil
}
continue
}
handler, ok := runner.handlers[job.Kind]
if !ok {
failure := fmt.Sprintf("no handler registered for job kind %q", job.Kind)
if err := runner.store.MarkFailed(ctx, job.ID, failure); err != nil {
return err
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := handler(ctx, *job); err != nil {
if ctx.Err() != nil {
return nil
}
failure := strings.TrimSpace(err.Error())
if failure == "" {
failure = "job handler returned an empty error"
}
if markErr := runner.store.MarkFailed(ctx, job.ID, failure); markErr != nil {
return markErr
}
runner.logger.Error("worker job failed", "jobID", job.ID, "kind", job.Kind, "error", failure)
continue
}
if err := runner.store.MarkSucceeded(ctx, job.ID); err != nil {
return err
}
runner.logger.Info("worker job succeeded", "jobID", job.ID, "kind", job.Kind)
}
}
func waitForNextPoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+211
View File
@@ -0,0 +1,211 @@
// Path: Backend/internal/worker/runner_test.go
package worker
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"moku-backend/internal/jobs"
)
func TestRunnerProcessesRegisteredJob(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-1",
Kind: jobs.KindBootstrapStructureMaterialize,
Payload: []byte(`{"installationId":"installation-1"}`),
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
if job.ID != "job-1" {
t.Fatalf("expected job id job-1, got %s", job.ID)
}
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called")
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-1" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
func TestRunnerMarksFailedWhenHandlerErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-2",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
return errors.New("boom")
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.succeeded) != 0 {
t.Fatalf("expected no succeeded jobs, got %#v", store.succeeded)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if store.failed[0].jobID != "job-2" {
t.Fatalf("expected failed job id job-2, got %#v", store.failed[0])
}
if !strings.Contains(store.failed[0].failure, "boom") {
t.Fatalf("expected failure to mention handler error, got %#v", store.failed[0])
}
}
func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
job: &jobs.Job{
ID: "job-3",
Kind: "unknown.kind",
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), 0)
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if len(store.failed) != 1 {
t.Fatalf("expected one failed job, got %#v", store.failed)
}
if !strings.Contains(store.failed[0].failure, "no handler registered") {
t.Fatalf("expected missing handler failure, got %#v", store.failed[0])
}
}
func TestRunnerRetriesClaimErrors(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := &fakeJobStore{
claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")},
job: &jobs.Job{
ID: "job-4",
Kind: jobs.KindBootstrapStructureMaterialize,
},
cancel: cancel,
}
runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond)
handlerCalled := false
runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error {
handlerCalled = true
return nil
})
if err := runner.Run(ctx); err != nil {
t.Fatalf("runner returned error: %v", err)
}
if !handlerCalled {
t.Fatal("expected handler to be called after claim retry")
}
if store.claimAttempts < 2 {
t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts)
}
if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" {
t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded)
}
if len(store.failed) != 0 {
t.Fatalf("expected no failed jobs, got %#v", store.failed)
}
}
type fakeJobStore struct {
mu sync.Mutex
job *jobs.Job
claimed bool
claimErrors []error
claimAttempts int
succeeded []string
failed []fakeFailure
cancel context.CancelFunc
}
type fakeFailure struct {
jobID string
failure string
}
func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) {
store.mu.Lock()
defer store.mu.Unlock()
store.claimAttempts++
if len(store.claimErrors) > 0 {
err := store.claimErrors[0]
store.claimErrors = store.claimErrors[1:]
return nil, err
}
if store.claimed || store.job == nil {
return nil, nil
}
store.claimed = true
job := *store.job
return &job, nil
}
func (store *fakeJobStore) MarkSucceeded(ctx context.Context, jobID string) error {
store.mu.Lock()
store.succeeded = append(store.succeeded, jobID)
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
func (store *fakeJobStore) MarkFailed(ctx context.Context, jobID, failure string) error {
store.mu.Lock()
store.failed = append(store.failed, fakeFailure{jobID: jobID, failure: failure})
store.mu.Unlock()
if store.cancel != nil {
store.cancel()
}
return nil
}
+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 'export PATH="$PATH:/usr/local/go/bin"; 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
View File
@@ -17,6 +17,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@solidjs/router": "^0.16.1",
"@solidjs/start": "2.0.0-alpha.2", "@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0", "@solidjs/vite-plugin-nitro-2": "^0.1.0",
"lucide-solid": "^0.542.0", "lucide-solid": "^0.542.0",
+12
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@solidjs/router':
specifier: ^0.16.1
version: 0.16.1(solid-js@1.9.11)
'@solidjs/start': '@solidjs/start':
specifier: 2.0.0-alpha.2 specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)) version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
@@ -1210,6 +1213,11 @@ packages:
peerDependencies: peerDependencies:
solid-js: '>=1.8.4' solid-js: '>=1.8.4'
'@solidjs/router@0.16.1':
resolution: {integrity: sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg==}
peerDependencies:
solid-js: ^1.8.6
'@solidjs/start@2.0.0-alpha.2': '@solidjs/start@2.0.0-alpha.2':
resolution: {integrity: sha512-z56ATi3P07q8F5Io2I+RQrwjyWZtFZzpXN/J+8scf/gqrAW83LtgRkZFZjJaGH7i9WrHP+ep9F+ZiJ2gDHVBcw==} resolution: {integrity: sha512-z56ATi3P07q8F5Io2I+RQrwjyWZtFZzpXN/J+8scf/gqrAW83LtgRkZFZjJaGH7i9WrHP+ep9F+ZiJ2gDHVBcw==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -4485,6 +4493,10 @@ snapshots:
dependencies: dependencies:
solid-js: 1.9.11 solid-js: 1.9.11
'@solidjs/router@0.16.1(solid-js@1.9.11)':
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))': '@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
+8 -3
View File
@@ -1,12 +1,17 @@
// Path: Frontend/src/app.tsx // Path: Frontend/src/app.tsx
import type { JSX } from "solid-js"; import { Suspense, type JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell"; import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import "./styles/main.scss"; import "./styles/main.scss";
import "./styles/user-overrides.scss"; import "./styles/user-overrides.scss";
const App = (): JSX.Element => { const App = (): JSX.Element => {
return <AppShell />; return (
<Router root={(props): JSX.Element => <Suspense>{props.children}</Suspense>}>
<FileRoutes />
</Router>
);
}; };
export default App; export default App;
@@ -95,6 +95,8 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
position: relative; position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
border-top: 1px solid var(--shell-frame-border); border-top: 1px solid var(--shell-frame-border);
@@ -103,6 +105,14 @@
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.workspaceContent {
min-width: 0;
min-height: 0;
height: 100%;
overflow: auto;
scrollbar-gutter: stable;
}
.mobileWorkspaceView { .mobileWorkspaceView {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -1,23 +1,26 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx // Path: Frontend/src/components/app-shell/AppShell/AppShell.tsx
import { useLocation } from "@solidjs/router";
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 { AppShellDataProvider, useAppShellData } from "../data/app-shell.context"; import { AppShellDataProvider, useAppShellData } from "../data/app-shell.context";
import { LeftRail } from "../LeftRail/LeftRail"; import { getWorkspaceBreadcrumbSegments } from "../data/workspace-routes";
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;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)"; const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
const AppShellContent = (): JSX.Element => { const AppShellContent = (props: { children: JSX.Element }): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light"); const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false); const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
@@ -25,6 +28,7 @@ const AppShellContent = (): JSX.Element => {
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false); const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null); const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const location = useLocation();
onMount((): void => { onMount((): void => {
setThemeState(getDocumentTheme()); setThemeState(getDocumentTheme());
@@ -80,6 +84,16 @@ const AppShellContent = (): JSX.Element => {
setActiveMobileWorkspaceView(null); setActiveMobileWorkspaceView(null);
}; };
const workspaceBreadcrumb = (): string =>
[
appShellData.activeServer().name,
...getWorkspaceBreadcrumbSegments(location.pathname, {
activeProjectName: appShellData.activeProject().name,
activeDepartmentName: appShellData.activeDepartment().name,
activeTeamName: appShellData.activeDepartment().teamName,
}),
].join(" / ");
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 +138,18 @@ const AppShellContent = (): JSX.Element => {
<Show <Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null} when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={ fallback={
<WorkspaceHome <>
sidebarCollapsed={isSidebarCollapsed()} <WorkspaceTopBar
onToggleSidebarCollapse={(): void => { sidebarCollapsed={isSidebarCollapsed()}
setIsSidebarCollapsed((collapsed) => !collapsed); breadcrumb={workspaceBreadcrumb()}
}} onToggleSidebarCollapse={(): void => {
/> setIsSidebarCollapsed((collapsed) => !collapsed);
}}
/>
<div class={styles.workspaceContent} data-slot="workspace-content">
{props.children}
</div>
</>
} }
> >
<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,14 +182,15 @@ const AppShellContent = (): JSX.Element => {
setIsMobileWorkspaceBrowserOpen(false); setIsMobileWorkspaceBrowserOpen(false);
}} }}
/> />
<BootstrapWizard />
</div> </div>
); );
}; };
export const AppShell = (): JSX.Element => { export const AppShell = (props: { children: JSX.Element }): JSX.Element => {
return ( return (
<AppShellDataProvider> <AppShellDataProvider>
<AppShellContent /> <AppShellContent>{props.children}</AppShellContent>
</AppShellDataProvider> </AppShellDataProvider>
); );
}; };
@@ -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,13 +1,22 @@
// 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 { useNavigate } from "@solidjs/router";
import { useAppShellData } from "../data/app-shell.context"; import { useAppShellData } from "../data/app-shell.context";
import { getWorkspaceSurfaceRoute } from "../data/workspace-routes";
import styles from "./ServerDock.module.scss"; import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => { export const ServerDock = (): JSX.Element => {
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const navigate = useNavigate();
const activeServer = () => appShellData.activeServer(); const activeServer = () => appShellData.activeServer();
const handleAction = (actionId: string): void => {
if (actionId === "settings" || actionId === "server") {
navigate(getWorkspaceSurfaceRoute("settings"));
}
};
return ( return (
<section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer().kind}> <section class={styles.panel} aria-label="Server dock" data-ui="server-dock" data-server-kind={activeServer().kind}>
<div class={styles.identity} data-slot="server-dock-identity"> <div class={styles.identity} data-slot="server-dock-identity">
@@ -35,7 +44,15 @@ export const ServerDock = (): JSX.Element => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label} data-slot="server-dock-action" data-action-id={item.id}> <button
type="button"
class={styles.action}
aria-label={item.label}
title={item.label}
data-slot="server-dock-action"
data-action-id={item.id}
onClick={() => handleAction(item.id)}
>
<Icon size={16} strokeWidth={2} /> <Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span> <span class={styles.actionLabel}>{item.label}</span>
</button> </button>
@@ -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,98 @@
// 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 { applyThemePresetById, resolvePreferredThemePresetId } from "../../../helper/themeRuntime";
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.");
}
const normalizedPayload = normalizeAppShellPayload(body.data);
const persistedThemePresetId = normalizedPayload.admin?.themePresetId?.trim();
if (persistedThemePresetId && persistedThemePresetId !== resolvePreferredThemePresetId()) {
await applyThemePresetById(persistedThemePresetId);
}
setPayload(normalizedPayload);
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),
admin: createMemo(() => payload()?.admin),
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,132 @@
// 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;
themePresetId?: 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>;
admin: Accessor<AppShellAdmin | 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,235 @@
// 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 WorkspaceStaticSurfaceKind = Exclude<WorkspaceStaticKind, "workspace">;
export type WorkspaceItemTypeId = string;
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticSurfaceKind;
};
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;
};
@@ -0,0 +1,79 @@
// Path: Frontend/src/components/app-shell/data/workspace-routes.ts
import type { WorkspaceStaticKind } from "./shell.types";
export type WorkspaceSurfaceKind = Exclude<WorkspaceStaticKind, "workspace">;
export type SettingsSectionKind = "account" | "workspace" | "server" | "theme" | "security" | "members";
export type WorkspaceBreadcrumbContext = {
activeProjectName: string;
activeDepartmentName: string;
activeTeamName?: string;
};
const workspaceSurfaceRoutes: Record<WorkspaceSurfaceKind, string> = {
home: "/workspace/home",
settings: "/settings",
};
export const getWorkspaceSurfaceRoute = (surface: WorkspaceSurfaceKind): string => workspaceSurfaceRoutes[surface];
const settingsSectionRoutes: Record<SettingsSectionKind, string> = {
account: "/settings/account",
workspace: "/settings/workspace",
server: "/settings/server",
theme: "/settings/theme",
security: "/settings/security",
members: "/settings/members",
};
export const getSettingsSectionRoute = (section: SettingsSectionKind): string => settingsSectionRoutes[section];
export const getWorkspaceSurfaceFromPathname = (pathname: string): WorkspaceSurfaceKind => {
if (pathname.startsWith(workspaceSurfaceRoutes.settings) || pathname.startsWith("/workspace/settings")) {
return "settings";
}
return "home";
};
export const getWorkspaceSurfaceBreadcrumbLabel = (pathname: string): string => {
const surface = getWorkspaceSurfaceFromPathname(pathname);
return surface === "settings" ? "Settings" : "Home";
};
const getSettingsSectionFromPathname = (pathname: string): SettingsSectionKind | null => {
const matchedEntry = (Object.entries(settingsSectionRoutes) as Array<[SettingsSectionKind, string]>).find(([, route]) => pathname.startsWith(route));
return matchedEntry?.[0] ?? null;
};
export const getWorkspaceBreadcrumbSegments = (
pathname: string,
context: WorkspaceBreadcrumbContext,
): string[] => {
const settingsSection = getSettingsSectionFromPathname(pathname);
if (pathname === workspaceSurfaceRoutes.settings || pathname === "/workspace/settings") {
return ["Settings"];
}
if (settingsSection) {
switch (settingsSection) {
case "account":
return ["Users", "Settings"];
case "workspace":
return [context.activeProjectName, "Settings"];
case "server":
return ["Settings"];
case "theme":
return ["Personal", "Settings"];
case "security":
return ["Users", "Settings"];
case "members":
return [context.activeTeamName || context.activeDepartmentName || "Members", "Users"];
}
}
return [context.activeProjectName, getWorkspaceSurfaceBreadcrumbLabel(pathname)];
};
@@ -0,0 +1,173 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.data.ts
export type BootstrapStepKey = "persona" | "instance" | "mode" | "admin" | "structure";
export type BootstrapStepDefinition = {
id: BootstrapStepKey;
title: string;
buttonLabel: string;
};
export type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
export type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
export type AdminForm = {
displayName: string;
email: string;
password: string;
};
export type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
export type BootstrapPersona = "personal" | "enthusiast" | "team" | "organization";
export type BootstrapPersonaDefinition = {
id: BootstrapPersona;
title: string;
isAvailable: boolean;
bestFor: string;
bullets: readonly string[];
defaults: {
protocol: InstanceForm["protocol"];
access: InstanceForm["access"];
host: string;
mode: ModeForm["mode"];
namePlaceholder: string;
departmentName: string;
teamName: string;
projectName: string;
};
};
export const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ id: "persona", title: "What are you setting up your server for?", buttonLabel: "Continue" },
{ id: "instance", title: "Connection details", buttonLabel: "Save and continue" },
{ id: "mode", title: "Server identity", buttonLabel: "Save and continue" },
{ id: "admin", title: "Admin account", buttonLabel: "Save and continue" },
{ id: "structure", title: "Initial structure", buttonLabel: "Submit" },
];
export const defaultInstanceForm: InstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
};
export const defaultModeForm: ModeForm = {
mode: "personal",
name: "",
};
export const defaultAdminForm: AdminForm = {
displayName: "Admin",
email: "admin@example.com",
password: "",
};
export const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
};
export const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
};
export const defaultStructureForm: StructureForm = {
...personalStructureDefaults,
projectName: "Project",
};
export const bootstrapPersonaDefinitions: readonly BootstrapPersonaDefinition[] = [
{
id: "personal",
title: "Personal",
isAvailable: true,
bestFor: "Best for low maintenance, personal use",
bullets: ["Preconfigured for personal use", "Low setup time", "Easy to manage"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "enthusiast",
title: "Self Hosted Enthusiast",
isAvailable: true,
bestFor: "Best for people who want to customize their server",
bullets: ["Networking knowledge", "Comfortable with tinkering", "Willing to troubleshoot issues"],
defaults: {
protocol: "https",
access: "remote",
host: "moku.local",
mode: "personal",
namePlaceholder: "Personal Server",
departmentName: "Default",
teamName: "Personal",
projectName: "Project",
},
},
{
id: "team",
title: "Team",
isAvailable: true,
bestFor: "Best for low maintenance but for small team",
bullets: ["Built-in collaboration with low setup time", "Keeps the shared structure simple", "Good for a small product, design, or delivery team"],
defaults: {
protocol: "http",
access: "local",
host: "localhost",
mode: "organizational",
namePlaceholder: "Team Server",
departmentName: "Default",
teamName: "Core Team",
projectName: "Project",
},
},
{
id: "organization",
title: "Organization",
isAvailable: true,
bestFor: "Best for multiple teams and shared ownership",
bullets: ["SME to Organization", "Fine grained access control", "Better fit for teams with multiple departments"],
defaults: {
protocol: "https",
access: "remote",
host: "workspace.example.com",
mode: "organizational",
namePlaceholder: "Organization server name",
departmentName: "Operations",
teamName: "Platform Team",
projectName: "Moku",
},
},
];
export const workspaceHomeFieldTooltips = {
protocol: "Usually people use http for a local-only setup and https when the server will be reached over a domain or reverse proxy.",
access: "Usually people use local when Moku is only reached on the same machine or LAN, and remote when they plan to reach it from another network or public domain.",
host: "Examples people usually set here are localhost, moku.local, or a real domain like workspace.example.com depending on how they plan to reach the server.",
serverName: "This is the friendly name people usually give the server itself, for example Personal Server, Ronald's Server, Homelab, Studio, or Workspace.",
department: "Departments are the highest-level grouping for work. People usually use names like Default, Operations, Product, Design, or Engineering.",
team: "Teams sit inside a department. Common examples are Personal, Platform Team, Core Team, Delivery, or Design Systems.",
project: "Projects are the workspace or initiative people work inside. Common examples are Project, Shared Workspace, Moku, Client Portal, or Website Redesign.",
} as const;
@@ -0,0 +1,469 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import {
bootstrapPersonaDefinitions,
bootstrapStepDefinitions,
defaultAdminForm,
defaultInstanceForm,
defaultModeForm,
defaultStructureForm,
organizationalStructureDefaults,
personalStructureDefaults,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import { submitBootstrapStepRequest } from "./bootstrapWizard.api";
type AppShellBootstrapAdapter = {
installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined;
status: () => string;
reload: () => Promise<void>;
};
export type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
export type FieldTooltipState = {
text: string;
left: number;
top: number;
placement: "top" | "bottom";
};
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
error: "",
});
const materializationPollIntervalMs = 2000;
export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => {
const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [selectedPersona, setSelectedPersona] = createSignal<BootstrapPersona>("enthusiast");
const [hasChosenPersona, setHasChosenPersona] = createSignal(false);
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false);
const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false);
const [isWizardOpen, setIsWizardOpen] = createSignal(false);
const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const [fieldTooltip, setFieldTooltip] = createSignal<FieldTooltipState | null>(null);
const installation = createMemo(() => appShellData.installation());
const materializationState = createMemo<MaterializationState>(() => {
const status = installation()?.materializationStatus;
switch (status) {
case "pending":
case "running":
case "failed":
case "succeeded":
case "not_started":
return status;
default:
return installation()?.isBootstrapped ? "succeeded" : "not_started";
}
});
const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false);
const isMaterializationInFlight = createMemo(() => materializationState() === "pending" || materializationState() === "running");
const hasMaterializationFailed = createMemo(() => materializationState() === "failed");
const showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()));
const materializationStatusLabel = createMemo(() => {
switch (materializationState()) {
case "pending":
return "Materialization queued";
case "running":
return "Materialization running";
case "failed":
return "Materialization failed";
case "succeeded":
return "Ready";
default:
return "Not started";
}
});
const materializationMessage = createMemo(() => {
if (isMaterializationInFlight()) {
return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index.";
}
if (hasMaterializationFailed()) {
return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly.";
}
return "";
});
const personaDefinition = createMemo<BootstrapPersonaDefinition>(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!);
const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable);
const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team");
const activeBootstrapSteps = createMemo<readonly BootstrapStepDefinition[]>(() => {
if (usesCondensedBootstrapFlow()) {
return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!];
}
return bootstrapStepDefinitions;
});
const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona"));
createEffect(() => {
const defaults = personaDefinition().defaults;
setInstanceForm({
protocol: defaults.protocol,
access: defaults.access,
host: defaults.host,
});
setModeForm("mode", defaults.mode);
setStructureForm({
departmentName: defaults.departmentName,
teamName: defaults.teamName,
projectName: defaults.projectName,
});
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
const resetWizardState = (): void => {
setSelectedPersona("enthusiast");
setHasChosenPersona(false);
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
persona: initialSubmissionState(),
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
setIsFinishingBootstrapFlow(false);
};
createEffect(() => {
const shellStatus = appShellData.status();
if (shellStatus === "idle" || shellStatus === "loading") {
return;
}
if (shellStatus !== "success") {
return;
}
if (!isBootstrapPersisted()) {
setIsFinishingBootstrapFlow(false);
resetWizardState();
}
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState());
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (!isFinishingBootstrapFlow()) {
return;
}
if (isMaterializationInFlight() || hasMaterializationFailed()) {
return;
}
setIsFinishingBootstrapFlow(false);
setIsWizardOpen(false);
});
createEffect(() => {
if (!isBootstrapPersisted() || !isMaterializationInFlight()) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
const scheduleReload = (): void => {
timeoutId = window.setTimeout(async () => {
if (cancelled) {
return;
}
await appShellData.reload();
if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) {
scheduleReload();
}
}, materializationPollIntervalMs);
};
scheduleReload();
onCleanup(() => {
cancelled = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
});
});
const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder;
const bootstrapStepCount = createMemo(() => activeWizardSteps().length);
const currentStep = createMemo<BootstrapStepDefinition>(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!);
const currentWizardStepIndex = createMemo(() => {
const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id);
return visibleIndex >= 0 ? visibleIndex : 0;
});
const wizardProgressPercent = createMemo(() => {
const totalSteps = bootstrapStepCount();
const activeIndex = Math.max(currentWizardStepIndex(), 0);
if (totalSteps <= 1) {
return 100;
}
return (activeIndex / (totalSteps - 1)) * 100;
});
const wizardProgressFillWidth = createMemo(() => {
if (currentStepIndex() <= 0) {
return `${wizardProgressPercent()}%`;
}
return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`;
});
const currentStepState = createMemo<BootstrapSubmissionState>(() => stepState[currentStep().id]);
const isFirstStep = (): boolean => currentStepIndex() === 0;
const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1;
const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight();
createEffect(() => {
setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1));
});
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
try {
await submitBootstrapStepRequest(step, payload);
setStepState(step, {
status: "success",
error: "",
});
return true;
} catch (error) {
setStepState(step, {
status: "error",
error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`,
});
return false;
}
};
const payloadForStep = (step: BootstrapStepKey): unknown => {
switch (step) {
case "instance":
return instanceForm;
case "mode":
return modeForm;
case "admin":
return adminForm;
case "structure":
return structureForm;
}
};
const applyPersonaSelection = (persona: BootstrapPersona): void => {
const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona);
if (!definition?.isAvailable) {
return;
}
setSelectedPersona(persona);
setHasChosenPersona(true);
setStepState("persona", {
status: "success",
error: "",
});
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
return "Sending";
case "error":
return "Request failed";
default:
return "";
}
};
const submitCurrentStep = async (): Promise<void> => {
const step = currentStep().id;
if (step === "persona") {
applyPersonaSelection(selectedPersona());
return;
}
if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") {
const didPersistInstanceDefaults = await submitStep("instance", instanceForm);
if (!didPersistInstanceDefaults) {
return;
}
}
const didSucceed = await submitStep(step, payloadForStep(step));
if (!didSucceed) {
return;
}
if (step === "admin" && usesCondensedBootstrapFlow()) {
const didPersistStructureDefaults = await submitStep("structure", structureForm);
if (!didPersistStructureDefaults) {
return;
}
}
if (isLastStep()) {
await appShellData.reload();
const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed());
setIsFinishingBootstrapFlow(shouldShowFinishingState);
setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight());
setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState);
setIsBootstrapStateResolved(true);
return;
}
setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1));
};
const showFieldTooltip = (target: HTMLElement, text: string): void => {
const rect = target.getBoundingClientRect();
const placement = rect.top > 96 ? "top" : "bottom";
const viewportPadding = 20;
const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding);
const top = placement === "top" ? rect.top - 10 : rect.bottom + 10;
setFieldTooltip({ text, left, top, placement });
};
const hideFieldTooltip = (): void => {
setFieldTooltip(null);
};
const stepStatusLabel = (step: BootstrapStepDefinition): string => {
const state = stepState[step.id];
if (state.status === "success") {
return "Done";
}
if (state.status === "error") {
return "Needs retry";
}
return "";
};
return {
instanceForm,
setInstanceForm,
modeForm,
setModeForm,
adminForm,
setAdminForm,
structureForm,
setStructureForm,
selectedPersona,
setSelectedPersona,
hasChosenPersona,
stepState,
isBootstrapStateResolved,
isBootstrapComplete,
isWizardOpen,
setIsWizardOpen,
isFinishingBootstrapFlow,
setIsFinishingBootstrapFlow,
fieldTooltip,
materializationState,
isMaterializationInFlight,
hasMaterializationFailed,
showBootstrapFinishingState,
materializationStatusLabel,
materializationMessage,
personaDefinition,
selectedPersonaIsAvailable,
usesCondensedBootstrapFlow,
activeWizardSteps,
bootstrapNamePlaceholder,
bootstrapStepCount,
currentStep,
currentWizardStepIndex,
wizardProgressFillWidth,
currentStepState,
isFirstStep,
canDismissWizard,
resetWizardState,
handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => {
event.preventDefault();
void submitCurrentStep();
},
applyPersonaSelection,
statusLabel,
showFieldTooltip,
hideFieldTooltip,
stepStatusLabel,
navigateBack: (): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
},
navigateToVisibleStep: (index: number): void => {
setCurrentStepIndex(index + 1);
},
};
};
@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.module.scss */
.viewport, .viewport,
.wizardLayer { .wizardLayer {
--workspace-content-max-width: var(--content-width-wide); --workspace-content-max-width: var(--content-width-wide);
@@ -19,14 +21,14 @@
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-height: calc(var(--control-size-md) - var(--space-3)); min-height: calc(var(--control-size-md) - var(--space-3));
padding: 0; padding: 0;
} }
.workspaceTopBarStart, .workspaceTopBarStart,
.workspaceTopBarEnd { .workspaceTopBarEnd {
min-width: calc(var(--control-size-md) - 0.5rem); min-width: calc(var(--control-size-md) - 0.5rem);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
@@ -42,7 +44,7 @@
} }
.workspaceBreadcrumb { .workspaceBreadcrumb {
@include text-caption; @include text-caption;
min-width: 0; min-width: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
white-space: nowrap; white-space: nowrap;
@@ -54,8 +56,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: calc(var(--control-size-md) - 0.5rem); width: calc(var(--control-size-md) - 0.5rem);
height: calc(var(--control-size-md) - 0.5rem); height: calc(var(--control-size-md) - 0.5rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent); border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 94%, transparent); background: color-mix(in srgb, var(--color-surface) 94%, transparent);
@@ -92,6 +94,21 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.heroStatus {
display: grid;
gap: var(--space-2);
justify-items: start;
}
.heroStatusMessage {
max-width: 64ch;
color: var(--color-text-muted);
}
.heroStatusMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.title { .title {
@include text-display; @include text-display;
font-family: var(--font-family-display); font-family: var(--font-family-display);
@@ -194,6 +211,19 @@
background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent); background: color-mix(in srgb, var(--color-success-surface, var(--color-surface-secondary)) 80%, transparent);
} }
.statusBadge[data-status="pending"],
.statusBadge[data-status="running"] {
color: var(--bootstrap-accent);
border-color: color-mix(in srgb, var(--bootstrap-accent) 38%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface-secondary));
}
.statusBadge[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 80%, transparent);
}
.statusBadge[data-status="error"] { .statusBadge[data-status="error"] {
color: var(--color-danger-text, var(--color-text)); color: var(--color-danger-text, var(--color-text));
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent); border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 68%, transparent);
@@ -240,11 +270,249 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.fieldLabelRow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.fieldInfoButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: help;
outline: none;
}
.fieldInfoButton:hover,
.fieldInfoButton:focus-visible {
color: var(--color-text);
}
.fieldTooltip {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 4);
pointer-events: none;
transform: translateX(-50%);
max-width: min(18rem, calc(100vw - 2rem));
}
.fieldTooltip[data-placement="top"] {
transform: translate(-50%, -100%);
}
.fieldTooltip[data-placement="bottom"] {
transform: translate(-50%, 0);
}
.fieldTooltipBubble {
position: relative;
padding: 0.45rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
box-shadow: var(--shadow-soft);
color: var(--color-text);
white-space: normal;
text-align: left;
line-height: 1.35;
}
.fieldTooltipBubble::after {
content: "";
position: absolute;
left: 50%;
width: 0.55rem;
height: 0.55rem;
background: color-mix(in srgb, var(--color-surface-elevated, var(--color-surface)) 96%, black 4%);
transform: translateX(-50%) rotate(45deg);
}
.fieldTooltip[data-placement="top"] .fieldTooltipBubble::after {
top: calc(100% - 0.3rem);
border-right: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldTooltip[data-placement="bottom"] .fieldTooltipBubble::after {
bottom: calc(100% - 0.3rem);
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 48%, transparent);
}
.fieldHelp { .fieldHelp {
@include text-caption; @include text-caption;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.personaIntro {
display: grid;
gap: var(--space-2);
}
.personaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.personaCard {
appearance: none;
position: relative;
display: grid;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
text-align: left;
overflow: hidden;
transition:
transform 180ms var(--easing-standard),
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
}
.personaCard:hover,
.personaCard:focus-visible,
.personaCard[data-selected="true"] {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 32%, var(--color-border));
background: color-mix(in srgb, var(--bootstrap-accent) 7%, var(--color-surface));
box-shadow: var(--shadow-soft);
}
.personaCard:focus-visible {
outline: none;
box-shadow:
var(--shadow-soft),
0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 16%, transparent);
}
.personaCard[data-available="false"] {
opacity: 0.9;
}
.personaCard[data-available="false"]:hover,
.personaCard[data-available="false"]:focus-visible,
.personaCard[data-available="false"][data-selected="true"] {
transform: none;
border-color: color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
box-shadow: none;
}
.personaCardMedia {
position: relative;
display: flex;
align-items: center;
justify-content: center;
justify-self: center;
align-self: center;
width: min(100%, 16rem);
aspect-ratio: 1 / 1;
border: 1px dashed color-mix(in srgb, var(--color-border-strong) 40%, transparent);
border-radius: calc(var(--radius-xl) - var(--space-1));
background: linear-gradient(
135deg,
color-mix(in srgb, var(--color-surface-elevated) 92%, transparent),
color-mix(in srgb, var(--color-surface-secondary) 88%, transparent)
);
transition:
filter 180ms var(--easing-standard),
opacity 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.personaCardBody {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
pointer-events: none;
z-index: 1;
}
.personaCardTitle {
@include text-title;
margin: 0;
max-width: min(100%, 14rem);
padding: 0.35rem 0.65rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
backdrop-filter: blur(10px);
color: var(--color-text);
}
.personaCardDetails {
display: grid;
gap: var(--space-2);
max-height: 0;
opacity: 0;
overflow: hidden;
align-self: end;
padding: var(--space-3);
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 18%, transparent),
color-mix(in srgb, var(--color-surface) 92%, transparent)
);
backdrop-filter: blur(12px);
transition:
max-height 180ms var(--easing-standard),
opacity 160ms var(--easing-standard);
}
.personaCard:hover .personaCardMedia,
.personaCard:focus-visible .personaCardMedia,
.personaCard[data-selected="true"] .personaCardMedia {
filter: brightness(0.72);
opacity: 0.92;
transform: scale(0.985);
}
.personaCard:hover .personaCardDetails,
.personaCard:focus-visible .personaCardDetails,
.personaCard[data-selected="true"] .personaCardDetails {
max-height: 12rem;
opacity: 1;
}
.personaCard[data-available="false"]:hover .personaCardMedia,
.personaCard[data-available="false"]:focus-visible .personaCardMedia,
.personaCard[data-available="false"][data-selected="true"] .personaCardMedia {
filter: brightness(0.82);
opacity: 0.96;
transform: none;
}
.personaBestFor,
.personaBulletList {
margin: 0;
color: var(--color-text);
}
.personaBulletList {
padding-left: 1rem;
display: grid;
gap: 0.25rem;
}
.personaAvailability {
@include text-caption;
margin: 0;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field input, .field input,
.field select { .field select {
min-height: var(--control-size-md); min-height: var(--control-size-md);
@@ -342,7 +610,7 @@
.primaryButton:hover, .primaryButton:hover,
.secondaryButton:hover, .secondaryButton:hover,
.wizardCloseButton:hover, .wizardCloseButton:hover,
.wizardStepButton:hover { .wizardProgressStep:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -417,72 +685,93 @@
.wizardBody { .wizardBody {
display: grid; display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: var(--space-4); gap: var(--space-4);
min-height: 0; min-height: 0;
} }
.wizardSidebar { .wizardProgress {
position: relative;
display: grid; display: grid;
gap: var(--space-4);
align-content: start;
}
.wizardSidebarSection {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressTrack {
display: grid; position: absolute;
gap: var(--space-2); left: calc((var(--control-size-md) - var(--space-2)) / 2);
right: calc((var(--control-size-md) - var(--space-2)) / 2);
top: calc((var(--control-size-md) - var(--space-2)) / 2);
height: 2px;
background: color-mix(in srgb, var(--color-border) 72%, transparent);
transform: translateY(-50%);
pointer-events: none;
} }
.wizardStepButton { .wizardProgressFill {
width: 100%; height: 100%;
display: grid; border-radius: 999px;
grid-template-columns: auto minmax(0, 1fr); background: color-mix(in srgb, var(--bootstrap-accent) 72%, white 8%);
transition: width 220ms var(--easing-standard);
}
.wizardProgressSteps {
display: flex;
align-items: center; align-items: center;
text-align: left; justify-content: space-between;
padding: var(--space-2) var(--space-3); gap: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface-secondary) 84%, transparent);
} }
.wizardStepButton[data-active="true"] { .wizardProgressStep {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent); position: relative;
background: color-mix(in srgb, var(--bootstrap-accent) 10%, var(--color-surface)); z-index: 1;
flex: 0 0 auto;
display: inline-flex;
justify-content: center;
justify-items: center;
text-align: center;
padding: 0;
border: 0;
background: transparent;
} }
.wizardStepButton:disabled { .wizardProgressStep:disabled {
opacity: 0.56;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.wizardStepIndex { .wizardProgressIndex {
width: calc(var(--control-size-md) - var(--space-2)); width: calc(var(--control-size-md) - var(--space-2));
height: calc(var(--control-size-md) - var(--space-2)); height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-pill); border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 80%, transparent); border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.wizardProgressStep[data-active="true"] .wizardProgressIndex,
.wizardProgressStep[data-complete="true"] .wizardProgressIndex {
border-color: color-mix(in srgb, var(--bootstrap-accent) 42%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 12%, var(--color-surface));
color: var(--color-text); color: var(--color-text);
} }
.wizardStepCopy { .wizardProgressStep[data-active="true"] .wizardProgressIndex {
min-width: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--bootstrap-accent) 14%, transparent);
display: grid;
gap: 0.125rem;
} }
.wizardStepCopy strong { .wizardProgressStep:not(:disabled):hover .wizardProgressIndex,
@include text-label; .wizardProgressStep:not(:disabled):focus-visible .wizardProgressIndex {
} transform: translateY(-1px);
border-color: color-mix(in srgb, var(--bootstrap-accent) 36%, var(--color-border));
.wizardStepCopy small { color: var(--color-text);
@include text-caption;
color: var(--color-text-muted);
} }
.wizardStepPanel { .wizardStepPanel {
@@ -490,7 +779,116 @@
gap: var(--space-3); gap: var(--space-3);
} }
.wizardFinishPanel {
display: grid;
gap: var(--space-4);
justify-items: stretch;
padding: var(--space-2) 0 0;
min-height: min(14rem, 32dvh);
align-content: center;
}
.wizardFinishShell {
display: grid;
gap: var(--space-4);
width: min(100%, 40rem);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.wizardFinishStatusRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.wizardFinishIndicator {
width: 2.5rem;
height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent);
}
.wizardFinishIndicator[data-status="failed"] {
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent);
background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent);
}
.wizardFinishSpinner {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent);
border-top-color: var(--bootstrap-accent);
animation: wizardFinishSpin 900ms linear infinite;
}
.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner {
border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent);
border-top-color: var(--color-danger-text, var(--color-text));
animation: none;
transform: rotate(45deg);
border-radius: var(--radius-sm);
width: 1rem;
height: 1rem;
}
.wizardFinishCopy {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.wizardFinishTitle {
@include text-title;
margin: 0;
}
.wizardFinishDescription,
.wizardFinishMessage,
.wizardFinishHint {
margin: 0;
color: var(--color-text-muted);
}
.wizardFinishMessage[data-status="failed"] {
color: var(--color-danger-text, var(--color-text));
}
.wizardFinishHint {
@include text-caption;
}
.wizardFinishActions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding-top: var(--space-1);
}
@keyframes wizardFinishSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@include respond-down(tablet) { @include respond-down(tablet) {
.personaGrid {
grid-template-columns: 1fr;
}
.summaryGrid, .summaryGrid,
.wizardBody { .wizardBody {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -536,21 +934,20 @@
} }
.wizardHeader, .wizardHeader,
.wizardBody, .wizardBody {
.wizardSidebar {
gap: var(--space-3); gap: var(--space-3);
} }
.wizardSteps { .wizardProgressSteps {
grid-auto-flow: column; justify-content: flex-start;
grid-auto-columns: minmax(10rem, 1fr); gap: var(--space-8);
overflow-x: auto; overflow-x: auto;
padding-bottom: var(--space-1); padding-bottom: var(--space-1);
scrollbar-width: thin; scrollbar-width: thin;
} }
.wizardStepButton { .wizardProgressStep {
min-width: 10rem; min-width: calc(var(--control-size-md) - var(--space-2));
} }
.wizardFormActions { .wizardFormActions {
@@ -0,0 +1,287 @@
// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.parts.tsx
import { For, Show, type JSX } from "solid-js";
import { CircleHelp } from "../../../lib/icons";
import {
organizationalStructureDefaults,
workspaceHomeFieldTooltips,
type AdminForm,
type BootstrapPersona,
type BootstrapPersonaDefinition,
type BootstrapStepDefinition,
type BootstrapStepKey,
type InstanceForm,
type ModeForm,
type StructureForm,
} from "./BootstrapWizard.data";
import styles from "./BootstrapWizard.module.scss";
type BootstrapSubmissionState = {
status: "idle" | "submitting" | "success" | "error";
error: string;
};
type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed";
type TooltipHandlers = {
onShowTooltip: (target: HTMLElement, text: string) => void;
onHideTooltip: () => void;
};
type FieldLabelWithTooltipProps = TooltipHandlers & {
label: string;
tooltip?: string;
};
export const FieldLabelWithTooltip = (props: FieldLabelWithTooltipProps): JSX.Element => (
<span class={styles.fieldLabelRow}>
<span class={styles.fieldLabel}>{props.label}</span>
<Show when={props.tooltip}>
<button
type="button"
class={styles.fieldInfoButton}
aria-label={`${props.label} help: ${props.tooltip}`}
onMouseEnter={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onMouseLeave={props.onHideTooltip}
onFocus={(event): void => props.onShowTooltip(event.currentTarget, props.tooltip!)}
onBlur={props.onHideTooltip}
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</Show>
</span>
);
type BootstrapFinishingStateProps = {
materializationState: MaterializationState;
statusLabel: string;
message: string;
isInFlight: boolean;
hasFailed: boolean;
onClose: () => void;
};
export const BootstrapFinishingState = (props: BootstrapFinishingStateProps): JSX.Element => (
<div class={styles.wizardFinishPanel} data-slot="bootstrap-wizard-finishing-state">
<div class={styles.wizardFinishShell}>
<div class={styles.wizardFinishStatusRow}>
<div class={styles.wizardFinishIndicator} data-status={props.materializationState} aria-hidden="true">
<div class={styles.wizardFinishSpinner} />
</div>
<div class={styles.wizardFinishCopy}>
<span class={styles.wizardStepEyebrow}>Bootstrap status</span>
<h3 class={styles.wizardFinishTitle}>Finishing setup</h3>
<p class={styles.wizardFinishDescription}>
We saved your initial bootstrap. The server is finishing the last background setup steps now.
</p>
</div>
</div>
<div class={styles.statusBadge} data-status={props.materializationState}>{props.statusLabel}</div>
<Show when={props.message}>
<p class={styles.wizardFinishMessage} data-status={props.materializationState}>{props.message}</p>
</Show>
<Show when={props.isInFlight}>
<p class={styles.wizardFinishHint}>This window will close automatically when setup is complete.</p>
</Show>
</div>
<Show when={props.hasFailed}>
<div class={styles.wizardFinishActions}>
<button type="button" class={styles.secondaryButton} onClick={props.onClose}>Close</button>
</div>
</Show>
</div>
);
type BootstrapWizardProgressProps = {
steps: readonly BootstrapStepDefinition[];
currentStepId: BootstrapStepKey;
currentWizardStepIndex: number;
stepState: Record<BootstrapStepKey, BootstrapSubmissionState>;
bootstrapStepCount: number;
wizardProgressFillWidth: string;
stepStatusLabel: (step: BootstrapStepDefinition) => string;
onSelectStep: (index: number) => void;
};
export const BootstrapWizardProgress = (props: BootstrapWizardProgressProps): JSX.Element => (
<div class={styles.wizardProgress} data-slot="bootstrap-wizard-progress">
<div class={styles.wizardProgressTrack} aria-hidden="true">
<div class={styles.wizardProgressFill} style={{ width: props.wizardProgressFillWidth }} />
</div>
<nav class={styles.wizardProgressSteps} aria-label="Bootstrap steps" style={{ "--wizard-progress-step-count": props.bootstrapStepCount }}>
<For each={props.steps}>
{(step, index): JSX.Element => (
<button
type="button"
class={styles.wizardProgressStep}
data-active={step.id === props.currentStepId ? "true" : "false"}
data-complete={props.stepState[step.id].status === "success" ? "true" : "false"}
disabled={index() > props.currentWizardStepIndex}
onClick={(): void => {
if (index() <= props.currentWizardStepIndex) {
props.onSelectStep(index());
}
}}
aria-label={`Step ${index() + 1}${props.stepStatusLabel(step) ? `, ${props.stepStatusLabel(step)}` : ""}`}
>
<span class={styles.wizardProgressIndex}>{index() + 1}</span>
</button>
)}
</For>
</nav>
</div>
);
type BootstrapPersonaStepProps = {
personas: readonly BootstrapPersonaDefinition[];
hasChosenPersona: boolean;
selectedPersona: BootstrapPersona;
selectedPersonaIsAvailable: boolean;
onSelectPersona: (persona: BootstrapPersona) => void;
};
export const BootstrapPersonaStep = (props: BootstrapPersonaStepProps): JSX.Element => (
<>
<div class={styles.personaGrid}>
<For each={props.personas}>
{(persona): JSX.Element => (
<button
type="button"
class={styles.personaCard}
data-selected={props.hasChosenPersona && persona.id === props.selectedPersona ? "true" : "false"}
data-available={persona.isAvailable ? "true" : "false"}
aria-pressed={props.hasChosenPersona && persona.id === props.selectedPersona}
onClick={(): void => props.onSelectPersona(persona.id)}
>
<div class={styles.personaCardMedia} aria-hidden="true" />
<div class={styles.personaCardBody}>
<h4 class={styles.personaCardTitle}>{persona.title}</h4>
<div class={styles.personaCardDetails}>
<p class={styles.personaBestFor}>{persona.bestFor}</p>
<ul class={styles.personaBulletList}>
<For each={persona.bullets}>{(bullet): JSX.Element => <li>{bullet}</li>}</For>
</ul>
<Show when={!persona.isAvailable}><p class={styles.personaAvailability}>Coming later</p></Show>
</div>
</div>
</button>
)}
</For>
</div>
<Show when={!props.selectedPersonaIsAvailable}>
<p class={styles.fieldHelp}>Only <strong>Self Hosted Enthusiast</strong> is wired up right now. The other setup paths will come next.</p>
</Show>
</>
);
type BootstrapInstanceStepProps = TooltipHandlers & {
instanceForm: InstanceForm;
onProtocolChange: (value: InstanceForm["protocol"]) => void;
onAccessChange: (value: InstanceForm["access"]) => void;
onHostChange: (value: string) => void;
};
export const BootstrapInstanceStep = (props: BootstrapInstanceStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Protocol" tooltip={workspaceHomeFieldTooltips.protocol} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.protocol} onInput={(event): void => props.onProtocolChange(event.currentTarget.value as InstanceForm["protocol"])}>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Access" tooltip={workspaceHomeFieldTooltips.access} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<select value={props.instanceForm.access} onInput={(event): void => props.onAccessChange(event.currentTarget.value as InstanceForm["access"])}>
<option value="local">local</option>
<option value="remote">remote</option>
</select>
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Host" tooltip={workspaceHomeFieldTooltips.host} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.instanceForm.host} onInput={(event): void => props.onHostChange(event.currentTarget.value)} placeholder="localhost or app.example.com" />
</label>
</>
);
type BootstrapModeStepProps = TooltipHandlers & {
modeForm: ModeForm;
structureForm: StructureForm;
usesCondensedBootstrapFlow: boolean;
selectedPersona: BootstrapPersona;
namePlaceholder: string;
onNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
};
export const BootstrapModeStep = (props: BootstrapModeStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Server name" tooltip={workspaceHomeFieldTooltips.serverName} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.modeForm.name} required onInput={(event): void => props.onNameChange(event.currentTarget.value)} placeholder={props.namePlaceholder} />
</label>
<Show when={props.usesCondensedBootstrapFlow}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Default Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Project" />
</label>
</Show>
<Show when={props.selectedPersona === "team"}>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team name" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder="Core Team" />
</label>
</Show>
</>
);
type BootstrapAdminStepProps = {
adminForm: AdminForm;
onDisplayNameChange: (value: string) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
};
export const BootstrapAdminStep = (props: BootstrapAdminStepProps): JSX.Element => (
<>
<label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span>
<input type="text" value={props.adminForm.displayName} onInput={(event): void => props.onDisplayNameChange(event.currentTarget.value)} placeholder="Admin" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Email</span>
<input type="email" value={props.adminForm.email} onInput={(event): void => props.onEmailChange(event.currentTarget.value)} placeholder="admin@example.com" />
</label>
<label class={styles.field}>
<span class={styles.fieldLabel}>Password</span>
<input type="password" value={props.adminForm.password} onInput={(event): void => props.onPasswordChange(event.currentTarget.value)} placeholder="Create a strong password" />
<small class={styles.fieldHelp}>Use at least 12 characters with uppercase, lowercase, numbers, and symbols.</small>
</label>
</>
);
type BootstrapStructureStepProps = TooltipHandlers & {
mode: ModeForm["mode"];
structureForm: StructureForm;
onDepartmentNameChange: (value: string) => void;
onTeamNameChange: (value: string) => void;
onProjectNameChange: (value: string) => void;
};
export const BootstrapStructureStep = (props: BootstrapStructureStepProps): JSX.Element => (
<>
<label class={styles.field}>
<FieldLabelWithTooltip label="Department" tooltip={workspaceHomeFieldTooltips.department} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.departmentName} disabled={props.mode === "personal"} onInput={(event): void => props.onDepartmentNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.departmentName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Team" tooltip={workspaceHomeFieldTooltips.team} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.teamName} disabled={props.mode === "personal"} onInput={(event): void => props.onTeamNameChange(event.currentTarget.value)} placeholder={organizationalStructureDefaults.teamName} />
</label>
<label class={styles.field}>
<FieldLabelWithTooltip label="Project" tooltip={workspaceHomeFieldTooltips.project} onShowTooltip={props.onShowTooltip} onHideTooltip={props.onHideTooltip} />
<input type="text" value={props.structureForm.projectName} onInput={(event): void => props.onProjectNameChange(event.currentTarget.value)} placeholder="Moku" />
</label>
</>
);
@@ -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));
}
};
@@ -0,0 +1,86 @@
// Path: Frontend/src/components/settings/SettingsDetailPage/SettingsDetailPage.module.scss
.viewport {
width: 100%;
max-width: min(72rem, 100%);
min-height: 100%;
padding: var(--space-5) var(--space-6) calc(var(--space-7) + env(safe-area-inset-bottom));
box-sizing: border-box;
display: grid;
gap: var(--space-6);
align-content: start;
}
.hero,
.panel {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
padding: var(--space-6);
display: grid;
gap: var(--space-2);
}
.hero {
border-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-border-subtle));
box-shadow:
0 10px 24px color-mix(in srgb, var(--color-accent) 8%, transparent),
var(--shadow-sm);
}
.backLink {
width: fit-content;
text-decoration: none;
color: color-mix(in srgb, var(--color-accent) 34%, var(--color-text-primary));
@include text-label;
}
.backLink:hover {
text-decoration: underline;
}
.title,
.panelTitle {
margin: 0;
color: var(--color-text-primary);
}
.title {
@include text-display;
line-height: 1.02;
letter-spacing: -0.02em;
}
.panelTitle {
@include text-title;
line-height: 1.12;
}
.description,
.focusList {
margin: 0;
color: var(--color-text-secondary);
@include text-body;
}
.focusList {
padding-left: 1.1rem;
display: grid;
gap: 0.35rem;
}
.focusList li::marker {
color: color-mix(in srgb, var(--color-accent) 44%, var(--color-text-secondary));
}
@media (max-width: 48rem) {
.viewport {
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom));
}
.hero,
.panel {
padding: var(--space-4);
}
}
@@ -0,0 +1,32 @@
// Path: Frontend/src/components/settings/SettingsDetailPage/SettingsDetailPage.tsx
import { A } from "@solidjs/router";
import { For, type JSX } from "solid-js";
import styles from "./SettingsDetailPage.module.scss";
type SettingsDetailPageProps = {
title: string;
description: string;
focus: readonly string[];
};
export const SettingsDetailPage = (props: SettingsDetailPageProps): JSX.Element => {
return (
<main class={styles.viewport}>
<section class={styles.hero}>
<A class={styles.backLink} href="/settings">
Back to Settings
</A>
<h1 class={styles.title}>{props.title}</h1>
<p class={styles.description}>{props.description}</p>
</section>
<section class={styles.panel}>
<h2 class={styles.panelTitle}>Focus areas</h2>
<ul class={styles.focusList}>
<For each={props.focus}>{(item) => <li>{item}</li>}</For>
</ul>
</section>
</main>
);
};
@@ -0,0 +1,229 @@
// Path: Frontend/src/components/settings/SettingsWorkspace/SettingsWorkspace.module.scss
.viewport {
--settings-page-max-width: min(88rem, 100%);
min-height: 100%;
box-sizing: border-box;
display: grid;
gap: var(--space-6);
padding-top: var(--space-5);
padding-right: var(--space-6);
padding-bottom: calc(var(--space-7) + env(safe-area-inset-bottom));
padding-left: var(--space-6);
align-content: start;
}
.hero {
display: grid;
gap: var(--space-4);
width: 100%;
max-width: var(--settings-page-max-width);
padding-inline: var(--space-6);
box-sizing: border-box;
}
.heroCopy,
.card {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
}
.heroCopy {
padding: var(--space-6);
display: grid;
gap: var(--space-2);
position: relative;
background: var(--color-surface-raised);
border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border-subtle));
box-shadow:
0 12px 28px color-mix(in srgb, var(--color-accent) 8%, transparent),
var(--shadow-sm);
}
.eyebrow,
.sectionEyebrow {
margin: 0;
color: color-mix(in srgb, var(--color-accent) 42%, var(--color-text-tertiary));
@include text-label;
}
.title,
.sectionTitle,
.cardTitle {
margin: 0;
color: var(--color-text-primary);
}
.title {
@include text-display;
max-width: 13ch;
text-wrap: balance;
letter-spacing: -0.025em;
line-height: 1.02;
}
.description,
.sectionDescription,
.cardDescription,
.focusList {
margin: 0;
color: var(--color-text-secondary);
@include text-body;
}
.focusList {
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
}
.sectionStack {
width: 100%;
max-width: var(--settings-page-max-width);
display: grid;
gap: var(--space-5);
padding-inline: var(--space-6);
box-sizing: border-box;
}
.section {
display: grid;
gap: var(--space-4);
}
.sectionHeader {
display: grid;
gap: var(--space-2);
}
.sectionTitle {
@include text-title;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.card {
padding: var(--space-5);
display: grid;
gap: var(--space-3);
align-items: start;
text-decoration: none;
color: inherit;
position: relative;
background: var(--color-surface-raised);
transition:
transform 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}
.card::before {
content: none;
}
.card::after {
content: none;
}
.card:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border-strong));
box-shadow:
0 10px 22px color-mix(in srgb, var(--color-accent) 8%, transparent),
var(--shadow-md);
}
.card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-accent) 54%, transparent);
outline-offset: 2px;
border-color: color-mix(in srgb, var(--color-accent) 26%, var(--color-border-strong));
box-shadow: var(--shadow-md);
}
.cardHeader {
display: flex;
align-items: center;
gap: var(--space-3);
position: relative;
z-index: 1;
}
.cardIconWrap {
width: 2.75rem;
height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
background: linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 14%, transparent), color-mix(in srgb, var(--color-accent-soft) 22%, transparent));
color: var(--color-text-primary);
border: 1px solid color-mix(in srgb, var(--color-accent) 16%, var(--color-border-subtle));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
0 6px 14px color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.cardBody {
display: grid;
gap: var(--space-2);
position: relative;
z-index: 1;
}
.cardTitle {
@include text-title;
line-height: 1.1;
}
.focusList li::marker {
color: color-mix(in srgb, var(--color-accent) 44%, var(--color-text-secondary));
}
.card:nth-child(2n) .cardIconWrap {
background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, transparent), color-mix(in srgb, var(--color-accent-soft) 22%, transparent));
border-color: color-mix(in srgb, #8b5cf6 16%, var(--color-border-subtle));
}
.card:nth-child(3n) .cardIconWrap {
background: linear-gradient(180deg, color-mix(in srgb, #06b6d4 12%, transparent), color-mix(in srgb, var(--color-accent) 18%, transparent));
border-color: color-mix(in srgb, #06b6d4 14%, var(--color-border-subtle));
}
@media (max-width: 64rem) {
.cardGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 48rem) {
.viewport {
padding-top: var(--space-4);
padding-right: var(--space-4);
padding-bottom: calc(var(--space-8) + env(safe-area-inset-bottom));
padding-left: var(--space-4);
}
.hero,
.sectionStack {
padding-inline: var(--space-4);
}
.heroCopy,
.card {
padding: var(--space-4);
}
.cardHeader {
align-items: flex-start;
}
.title {
max-width: none;
}
}
@@ -0,0 +1,95 @@
// Path: Frontend/src/components/settings/SettingsWorkspace/SettingsWorkspace.tsx
import { A } from "@solidjs/router";
import { For, type JSX } from "solid-js";
import { Bell, Home, Moon, Shield, Settings, User } from "../../../lib/icons";
import { getSettingsSectionRoute } from "../../app-shell/data/workspace-routes";
import styles from "./SettingsWorkspace.module.scss";
const sections = [
{
title: "Account Settings",
description: "Profile, identity, and account preferences for the active user.",
icon: User,
href: getSettingsSectionRoute("account"),
focus: ["Profile", "Notifications", "Personal defaults"],
},
{
title: "Workspace Settings",
description: "Core workspace behavior mapped to the project-level `settings.cbor` surface.",
icon: Home,
href: getSettingsSectionRoute("workspace"),
focus: ["Navigation defaults", "Workspace behavior", "Content rules"],
},
{
title: "Server Settings",
description: "Organization and server-wide controls that should live above individual workspaces.",
icon: Settings,
href: getSettingsSectionRoute("server"),
focus: ["Server identity", "Policies", "Operational defaults"],
},
{
title: "Theme Settings",
description: "Theme preferences and presentation settings that should eventually map cleanly to POSIX-backed configuration.",
icon: Moon,
href: getSettingsSectionRoute("theme"),
focus: ["Theme preset", "Density", "Visual preferences"],
},
{
title: "Security Settings",
description: "Authentication, sessions, and access hardening for users and organizations.",
icon: Shield,
href: getSettingsSectionRoute("security"),
focus: ["Sessions", "Access controls", "Recovery"],
},
{
title: "Members & Roles",
description: "People, invitations, and organization-level permission management.",
icon: Bell,
href: getSettingsSectionRoute("members"),
focus: ["Members", "Roles", "Invitations"],
},
] as const;
export const SettingsWorkspace = (): JSX.Element => {
return (
<main class={styles.viewport} data-ui="workspace-settings-page">
<section class={styles.hero}>
<div class={styles.heroCopy}>
<h1 class={styles.title}>Settings</h1>
</div>
</section>
<section class={styles.sectionStack}>
<section class={styles.section}>
<div class={styles.cardGrid}>
<For each={sections}>
{(section) => {
const Icon = section.icon;
return (
<A class={styles.card} href={section.href}>
<div class={styles.cardHeader}>
<div class={styles.cardIconWrap}>
<Icon size={18} strokeWidth={2} />
</div>
</div>
<div class={styles.cardBody}>
<h3 class={styles.cardTitle}>{section.title}</h3>
<p class={styles.cardDescription}>{section.description}</p>
<ul class={styles.focusList}>
<For each={section.focus}>{(focus) => <li>{focus}</li>}</For>
</ul>
</div>
</A>
);
}}
</For>
</div>
</section>
</section>
</main>
);
};
@@ -0,0 +1,234 @@
// Path: Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.module.scss
.viewport {
width: 100%;
max-width: min(80rem, 100%);
min-height: 100%;
padding: var(--space-5) var(--space-6) calc(var(--space-7) + env(safe-area-inset-bottom));
box-sizing: border-box;
display: grid;
gap: var(--space-5);
align-content: start;
}
.hero,
.card,
.errorBanner {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
box-shadow: var(--shadow-sm);
}
.hero {
padding: var(--space-6);
display: flex;
align-items: center;
gap: var(--space-4);
border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
box-shadow:
0 10px 24px color-mix(in srgb, var(--color-accent) 8%, transparent),
var(--shadow-sm);
}
.heroIcon {
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-accent) 14%, transparent),
color-mix(in srgb, var(--color-accent-soft) 22%, transparent)
);
border: 1px solid color-mix(in srgb, var(--color-accent) 16%, var(--color-border-subtle));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 8%, transparent),
0 6px 14px color-mix(in srgb, var(--color-accent) 7%, transparent);
color: var(--color-text-primary);
flex: 0 0 auto;
}
.heroCopy {
display: grid;
gap: var(--space-2);
}
.title,
.cardTitle {
margin: 0;
color: var(--color-text-primary);
}
.title {
@include text-display;
line-height: 1.02;
letter-spacing: -0.02em;
}
.description,
.cardDescription,
.errorBanner {
margin: 0;
color: var(--color-text-secondary);
@include text-body;
}
.errorBanner {
padding: var(--space-4) var(--space-5);
border-color: color-mix(in srgb, #ef4444 24%, var(--color-border-subtle));
color: color-mix(in srgb, #ef4444 64%, var(--color-text-primary));
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.card {
padding: var(--space-4);
display: grid;
gap: var(--space-4);
text-align: left;
color: inherit;
cursor: pointer;
transition:
transform 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}
.card:hover:not(:disabled) {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-strong));
box-shadow:
0 10px 22px color-mix(in srgb, var(--color-accent) 8%, transparent),
var(--shadow-md);
}
.card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-accent) 54%, transparent);
outline-offset: 2px;
}
.card:disabled {
cursor: default;
}
.cardSelected {
border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border-strong));
box-shadow:
0 10px 22px color-mix(in srgb, var(--color-accent) 10%, transparent),
var(--shadow-md);
}
.preview {
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
min-height: 9.5rem;
}
.previewCanvas {
height: 100%;
min-height: 9.5rem;
padding: 0.8rem;
box-sizing: border-box;
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
}
.previewTopbar {
height: 0.65rem;
border-radius: 999px;
opacity: 0.9;
}
.previewSurface {
border-radius: calc(var(--radius-lg) - 0.25rem);
padding: 0.9rem;
display: grid;
align-content: start;
gap: 0.55rem;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 6%, transparent);
}
.previewChip {
width: 2.8rem;
height: 0.55rem;
border-radius: 999px;
opacity: 0.95;
}
.previewLine,
.previewLineShort {
height: 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-primary) 10%, transparent);
}
.previewLineShort {
width: 58%;
}
.cardBody {
display: grid;
gap: var(--space-2);
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.cardTitle {
@include text-title;
line-height: 1.1;
}
.badge {
padding: 0.25rem 0.55rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--color-accent) 18%, var(--color-border-subtle));
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: color-mix(in srgb, var(--color-accent) 44%, var(--color-text-primary));
@include text-label;
white-space: nowrap;
}
@media (max-width: 64rem) {
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 48rem) {
.viewport {
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom));
}
.hero,
.card {
padding: var(--space-4);
}
.hero {
align-items: flex-start;
}
.title {
max-width: none;
}
.cardHeader {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,129 @@
// Path: Frontend/src/components/settings/ThemeSettingsPage/ThemeSettingsPage.tsx
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import { Moon } from "../../../lib/icons";
import { useAppShellData } from "../../app-shell/data/app-shell.context";
import { applyThemePresetById, resolvePreferredThemePresetId } from "../../../helper/themeRuntime";
import { themePresetMetas } from "../../../theme/presets";
import styles from "./ThemeSettingsPage.module.scss";
type SaveThemeResponse = {
data?: {
presetId?: string;
};
error?: { message?: string } | string;
message?: string;
};
export const ThemeSettingsPage = (): JSX.Element => {
const appShell = useAppShellData();
const [savingPresetId, setSavingPresetId] = createSignal<string | null>(null);
const [error, setError] = createSignal("");
const activePresetId = createMemo(() => {
return appShell.admin()?.themePresetId?.trim() || resolvePreferredThemePresetId();
});
const savePreset = async (presetId: string): Promise<void> => {
if (savingPresetId() || presetId === activePresetId()) {
return;
}
setSavingPresetId(presetId);
setError("");
try {
const response = await fetch(`${resolveAPIBase()}/settings/theme`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ presetId }),
});
const body = (await response.json().catch(() => ({}))) as SaveThemeResponse;
const errorMessage =
typeof body.message === "string"
? body.message
: typeof body.error === "string"
? body.error
: body.error?.message;
if (!response.ok) {
throw new Error(errorMessage || "Failed to save theme preset.");
}
await applyThemePresetById(presetId);
await appShell.reload();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Failed to save theme preset.");
} finally {
setSavingPresetId(null);
}
};
return (
<main class={styles.viewport}>
<section class={styles.hero}>
<div class={styles.heroIcon}>
<Moon size={18} strokeWidth={2} />
</div>
<div class={styles.heroCopy}>
<h1 class={styles.title}>Theme Settings</h1>
<p class={styles.description}>Pick the theme preset for your account.</p>
</div>
</section>
<Show when={error()}>
<div class={styles.errorBanner} role="alert">
{error()}
</div>
</Show>
<section class={styles.grid}>
<For each={themePresetMetas}>
{(preset) => {
const isSelected = () => activePresetId() === preset.id;
const isSaving = () => savingPresetId() === preset.id;
return (
<button
type="button"
classList={{
[styles.card]: true,
[styles.cardSelected]: isSelected(),
}}
onClick={() => void savePreset(preset.id)}
disabled={Boolean(savingPresetId())}
aria-pressed={isSelected()}
>
<div class={styles.preview} aria-hidden="true">
<div class={styles.previewCanvas} style={{ background: preset.preview.canvas }}>
<div class={styles.previewTopbar} style={{ background: preset.preview.accent }} />
<div class={styles.previewSurface} style={{ background: preset.preview.surface }}>
<div class={styles.previewChip} style={{ background: preset.preview.accent }} />
<div class={styles.previewLine} />
<div class={styles.previewLineShort} />
</div>
</div>
</div>
<div class={styles.cardBody}>
<div class={styles.cardHeader}>
<h2 class={styles.cardTitle}>{preset.name}</h2>
<Show when={isSelected() || isSaving()}>
<span class={styles.badge}>{isSaving() ? "Saving..." : "Current"}</span>
</Show>
</div>
<p class={styles.cardDescription}>{preset.description}</p>
</div>
</button>
);
}}
</For>
</section>
</main>
);
};
@@ -1,190 +0,0 @@
.browserLayer {
display: none;
}
@include respond-down(mobile) {
.browserLayer {
position: fixed;
inset: 0;
display: grid;
z-index: calc(var(--z-popover, 20) + 2);
background: color-mix(in srgb, var(--color-canvas) 96%, black 4%);
}
.sheet {
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 84%, transparent) 0%, transparent 8rem),
color-mix(in srgb, var(--color-canvas) 97%, black 3%);
overflow: clip;
}
.sheetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-5) + env(safe-area-inset-top, 0px)) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent);
}
.headerActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.brandBlock {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.brandEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.brandTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.brandContext {
@include text-caption;
color: var(--color-text-subtle);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.createButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
color: var(--color-text);
box-shadow: var(--shadow-soft);
font: inherit;
font-weight: var(--font-weight-semibold);
}
.sheetBody {
min-height: 0;
display: grid;
gap: var(--space-5);
padding: var(--space-5) var(--space-5) calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
overflow: auto;
}
.sectionBlock {
display: grid;
gap: var(--space-2);
}
.treeList,
.treeListItem {
list-style: none;
margin: 0;
padding: 0;
}
.treeList {
display: grid;
gap: 0;
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.treeListNested {
display: grid;
gap: 0;
}
.treeRow {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-3));
padding: var(--space-4) var(--space-2) var(--space-4) calc(var(--space-2) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.treeRowActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
}
.treeRowBranch {
font-weight: var(--font-weight-semibold);
}
.treeRowLead,
.treeRowTrail {
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.treeRowTrail {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.treeLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treeMeta {
@include text-caption;
min-width: 1rem;
text-align: right;
}
.treeChevron {
color: var(--color-text-subtle);
}
.treeListNested > .treeListItem > .treeRow {
min-height: calc(var(--control-size-lg) + var(--space-1));
}
}
@@ -1,247 +0,0 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { createLongPressGesture } from "../createLongPressGesture";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceStaticItems,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import styles from "./MobileWorkspaceBrowser.module.scss";
type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
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 sectionNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget());
};
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Mobile first pass only establishes the action-sheet IA and long-press behavior.
};
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={sectionNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
<Show when={looseNodes().length > 0}>
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="more">
<span class={styles.sectionLabel}>More</span>
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="more">
<WorkspaceTreeBranch nodes={looseNodes()} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</Show>
</div>
</section>
<WorkspaceMobileActionSheet
target={actionSheetTarget()}
onClose={closeActionSheet}
onSelect={handleActionSelect}
/>
</div>
</Show>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,961 +0,0 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createEffect, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { resolveAPIBase } from "../../../lib/api";
import { ChevronLeft, ChevronRight, Folder, ListCollapse, UnfoldVertical } from "../../../lib/icons";
import { useAppShellData } from "../data/app-shell.context";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import {
collectBranchNodeIds,
findTreeNodeDepth,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
type NavTreeAdapter,
type NavTreeDropTarget,
} from "../shared/navTreeDnd";
import {
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
getWorkspaceNodeIcon,
workspaceSidebarHeaderActions,
workspaceStaticItems,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import styles from "./WorkspaceSidebar.module.scss";
type WorkspaceSidebarProps = {
collapsed: boolean;
railCollapsed: boolean;
onToggleRailCollapse: () => void;
};
type PendingWorkspaceFolderDraft = {
parentId: string | null;
depth: number;
};
type WorkspaceDragTarget = NavTreeDropTarget;
type WorkspaceDragState = {
draggedNodeId: string;
dropTarget: WorkspaceDragTarget | null;
};
type PersistedWorkspaceFolderRecord = {
id: string;
label: string;
children?: PersistedWorkspaceFolderRecord[];
};
type WorkspaceFoldersResponse = {
data?: {
folders?: PersistedWorkspaceFolderRecord[];
renamedFolder?: PersistedWorkspaceFolderRecord;
previousFolderId?: string;
};
error?: string;
message?: string;
};
type PendingWorkspaceFolderRename = {
folderId: string;
depth: number;
};
const LONG_PRESS_MS = 320;
const getWorkspaceTreeNodeId = (node: WorkspaceTreeNode): string => node.id;
const buildPersistedWorkspaceFolderNodes = (
folders: readonly PersistedWorkspaceFolderRecord[],
): WorkspaceTreeNode[] =>
folders.map((folder) => ({
id: folder.id,
label: folder.label,
kind: "folder",
icon: Folder,
children: buildPersistedWorkspaceFolderNodes(folder.children ?? []),
}));
const readPersistedWorkspaceFolders = (body: WorkspaceFoldersResponse): PersistedWorkspaceFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const workspaceTreeAdapter: NavTreeAdapter<WorkspaceTreeNode> = {
getNodeId: getWorkspaceTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? (node.children ?? []) : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: [...children],
}
: node,
};
const FolderDraftRow = (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>
);
};
const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
const WorkspaceHomeEntry = (props: {
item: WorkspaceStaticItem;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const Icon = props.item.icon;
const target = createWorkspaceStaticTarget(props.item);
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
aria-current={props.item.active ? "page" : undefined}
aria-label={props.item.label}
title={props.item.label}
data-slot="workspace-static-item"
data-target-kind={target.kind}
data-active={props.item.active ? "true" : "false"}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{props.item.label}</span>
<Show when={props.item.meta}>
<span class={styles.itemMeta}>{props.item.meta}</span>
</Show>
</button>
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
parentId?: string | null;
depth?: number;
isFolderCollapsed: (folderId: string) => boolean;
onToggleFolder: (folderId: string) => void;
pendingFolderDraft: PendingWorkspaceFolderDraft | null;
pendingFolderName: string;
onPendingFolderNameChange: (value: string) => void;
onSubmitPendingFolder: () => void;
onCancelPendingFolder: () => void;
pendingFolderRename: PendingWorkspaceFolderRename | null;
pendingFolderRenameName: string;
onPendingFolderRenameChange: (value: string) => void;
onSubmitPendingFolderRename: () => void;
onCancelPendingFolderRename: () => void;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
onNodePointerDown: (event: PointerEvent, nodeId: string) => void;
onNodePointerMove: (event: PointerEvent, parentId: string | null, index: number, node: WorkspaceTreeNode) => void;
dragState: WorkspaceDragState | null;
isTreeClickSuppressed: () => boolean;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
const parentId = () => props.parentId ?? null;
return (
<ul class={styles.treeList} role="list">
<Show when={props.nodes.length === 0 && props.pendingFolderDraft?.parentId !== parentId()}>
<li>
<div class={styles.treeEmptySlot} style={{ "--tree-depth": String(depth()) }} />
</li>
</Show>
<For each={props.nodes}>
{(node, indexAccessor): JSX.Element => {
const Icon = getWorkspaceNodeIcon(node);
const target = createWorkspaceTreeTarget(node);
const isCollapsed = (): boolean => (node.kind === "folder" ? props.isFolderCollapsed(node.id) : false);
const isRenaming = (): boolean => props.pendingFolderRename?.folderId === node.id;
const isDraggedNode = (): boolean => props.dragState?.draggedNodeId === node.id;
const dropIntent = (): WorkspaceDragTarget["intent"] | null => {
if (props.dragState?.dropTarget?.targetNodeId !== node.id) {
return null;
}
return props.dragState.dropTarget.intent;
};
return (
<li>
<Show
when={node.kind === "folder" && isRenaming()}
fallback={
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(depth()) }}
aria-expanded={node.kind === "folder" ? !isCollapsed() : undefined}
aria-current={node.active ? "page" : undefined}
aria-label={node.label}
title={node.label}
data-slot="workspace-tree-item"
data-kind={node.kind}
data-item-type={node.kind === "item" ? node.itemType : undefined}
data-active={node.active ? "true" : "false"}
onClick={(): void => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
if (node.kind !== "folder") {
return;
}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
}
onPointerEnter={(event): void =>
props.onNodePointerMove(event, parentId(), indexAccessor(), node)
}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Show when={node.kind === "folder"}>
<ChevronRight
classList={{
[styles.folderChevron]: true,
[styles.folderChevronOpen]: !isCollapsed(),
}}
size={16}
strokeWidth={2}
/>
</Show>
<Icon 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>
}
>
<FolderDraftRow
depth={props.pendingFolderRename?.depth ?? depth()}
value={props.pendingFolderRenameName}
onInput={props.onPendingFolderRenameChange}
onSubmit={props.onSubmitPendingFolderRename}
onCancel={props.onCancelPendingFolderRename}
/>
</Show>
<Show when={node.kind === "folder" && !isCollapsed() && (((node.children?.length ?? 0) > 0) || props.pendingFolderDraft?.parentId === node.id)}>
<WorkspaceTreeBranch
nodes={node.children ?? []}
parentId={node.id}
depth={depth() + 1}
isFolderCollapsed={props.isFolderCollapsed}
onToggleFolder={props.onToggleFolder}
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}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
onNodePointerDown={props.onNodePointerDown}
onNodePointerMove={props.onNodePointerMove}
dragState={props.dragState}
isTreeClickSuppressed={props.isTreeClickSuppressed}
/>
</Show>
</li>
);
}}
</For>
<Show when={props.pendingFolderDraft && props.pendingFolderDraft.parentId === parentId()}>
<FolderDraftRow
depth={props.pendingFolderDraft?.depth ?? depth()}
value={props.pendingFolderName}
onInput={props.onPendingFolderNameChange}
onSubmit={props.onSubmitPendingFolder}
onCancel={props.onCancelPendingFolder}
/>
</Show>
</ul>
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const appShellData = useAppShellData();
const activeProject = () => appShellData.activeProject();
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const [workspaceTreeNodes, setWorkspaceTreeNodes] = createSignal<readonly WorkspaceTreeNode[]>(appShellData.workspaceTree());
const [persistedFolders, setPersistedFolders] = createSignal<readonly PersistedWorkspaceFolderRecord[]>([]);
const [collapsedFolderIds, setCollapsedFolderIds] = createSignal<readonly string[]>([]);
const [pendingFolderDraft, setPendingFolderDraft] = createSignal<PendingWorkspaceFolderDraft | null>(null);
const [pendingFolderName, setPendingFolderName] = createSignal("");
const [pendingFolderRename, setPendingFolderRename] = createSignal<PendingWorkspaceFolderRename | null>(null);
const [pendingFolderRenameName, setPendingFolderRenameName] = createSignal("");
const [dragState, setDragState] = createSignal<WorkspaceDragState | null>(null);
const [suppressNextTreeClick, setSuppressNextTreeClick] = createSignal(false);
let lastSelectedProjectId: string | null = null;
let latestPersistedFoldersRequest = 0;
const contextMenu = createWorkspaceContextMenuController();
let longPressTimer: number | undefined;
let suppressClickTimer: number | undefined;
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(appShellData.activeProject());
const isFolderCollapsed = (folderId: string): boolean => collapsedFolderIds().includes(folderId);
const folderIds = (): string[] => collectBranchNodeIds(workspaceTreeNodes(), workspaceTreeAdapter);
const totalFolderCount = (): number => folderIds().length;
const areAllFoldersCollapsed = (): boolean => {
const count = totalFolderCount();
return count > 0 && collapsedFolderIds().length >= count;
};
const workspaceFolderToggleLabel = (): string =>
areAllFoldersCollapsed() ? "Expand all folders" : "Collapse all folders";
const toggleFolder = (folderId: string): void => {
setCollapsedFolderIds((current) =>
current.includes(folderId) ? current.filter((id) => id !== folderId) : [...current, folderId],
);
};
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
};
const collapseAllFolders = (): void => {
setCollapsedFolderIds(folderIds());
};
const toggleAllFolders = (): void => {
if (areAllFoldersCollapsed()) {
expandAllFolders();
return;
}
collapseAllFolders();
};
const resetWorkspaceTreeInteractionState = (): void => {
setCollapsedFolderIds([]);
setPendingFolderDraft(null);
setPendingFolderName("");
setPendingFolderRename(null);
setPendingFolderRenameName("");
setDragState(null);
};
const syncWorkspaceTree = (): void => {
const nextTree = activeProject()?.id
? buildPersistedWorkspaceFolderNodes(persistedFolders())
: appShellData.workspaceTree();
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, workspaceTreeAdapter));
setWorkspaceTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((id) => availableFolderIds.has(id)));
};
const loadPersistedFolders = async (projectId: string): Promise<void> => {
const requestId = latestPersistedFoldersRequest + 1;
latestPersistedFoldersRequest = requestId;
if (!projectId) {
setPersistedFolders([]);
return;
}
if (!isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
headers: {
Accept: "application/json",
},
});
const body = (await response.json()) as WorkspaceFoldersResponse;
if (requestId !== latestPersistedFoldersRequest) {
return;
}
if (!response.ok) {
throw new Error(body.message || "Failed to load project tree folders.");
}
setPersistedFolders(readPersistedWorkspaceFolders(body));
} catch (error) {
if (requestId !== latestPersistedFoldersRequest) {
return;
}
console.error(error);
setPersistedFolders([]);
}
};
const clearLongPressTimer = (): void => {
if (longPressTimer !== undefined) {
window.clearTimeout(longPressTimer);
longPressTimer = undefined;
}
};
const suppressTreeClickTemporarily = (): void => {
setSuppressNextTreeClick(true);
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
suppressClickTimer = window.setTimeout(() => {
setSuppressNextTreeClick(false);
suppressClickTimer = undefined;
}, 80);
};
createEffect(() => {
syncWorkspaceTree();
});
createEffect(() => {
const projectId = activeProject()?.id ?? null;
if (lastSelectedProjectId === null) {
lastSelectedProjectId = projectId;
return;
}
if (projectId === lastSelectedProjectId) {
return;
}
lastSelectedProjectId = projectId;
resetWorkspaceTreeInteractionState();
});
createEffect(() => {
void loadPersistedFolders(activeProject()?.id ?? "");
});
onMount(() => {
const handlePointerUp = (): void => {
clearLongPressTimer();
const nextDragState = dragState();
if (!nextDragState?.dropTarget) {
if (nextDragState) {
suppressTreeClickTemporarily();
}
setDragState(null);
return;
}
suppressTreeClickTemporarily();
setWorkspaceTreeNodes((current) =>
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as WorkspaceDragTarget, workspaceTreeAdapter),
);
setDragState(null);
};
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== "Escape") {
return;
}
clearLongPressTimer();
if (dragState()) {
setDragState(null);
}
};
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
window.addEventListener("keydown", handleEscape);
onCleanup(() => {
clearLongPressTimer();
if (suppressClickTimer !== undefined) {
window.clearTimeout(suppressClickTimer);
}
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
window.removeEventListener("keydown", handleEscape);
});
});
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 submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = activeProject()?.id ?? "";
if (!draft) {
return;
}
if (!name) {
setPendingFolderDraft(null);
setPendingFolderName("");
return;
}
if (!projectId || !isUuidString(projectId)) {
cancelPendingFolder();
return;
}
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: draft.parentId,
}),
});
const body = (await response.json()) as WorkspaceFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to create project tree folder.");
}
setPersistedFolders(readPersistedWorkspaceFolders(body));
setPendingFolderDraft(null);
setPendingFolderName("");
} catch (error) {
console.error(error);
}
};
const deletePersistedFolder = async (folderId: string): Promise<void> => {
const projectId = activeProject()?.id ?? "";
if (!folderId || !projectId || !isUuidString(projectId)) {
return;
}
try {
const response = await fetch(
`${resolveAPIBase()}/projects/${projectId}/tree/folders?folderId=${encodeURIComponent(folderId)}`,
{
method: "DELETE",
headers: {
Accept: "application/json",
},
},
);
const body = (await response.json()) as WorkspaceFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to delete project tree folder.");
}
setPersistedFolders(readPersistedWorkspaceFolders(body));
setCollapsedFolderIds((current) => current.filter((id) => id !== folderId));
} catch (error) {
console.error(error);
}
};
const submitPendingFolderRename = async (): Promise<void> => {
const draft = pendingFolderRename();
const name = pendingFolderRenameName().trim();
const projectId = activeProject()?.id ?? "";
if (!draft) {
return;
}
if (!name) {
setPendingFolderRename(null);
setPendingFolderRenameName("");
return;
}
if (!projectId || !isUuidString(projectId)) {
cancelPendingFolderRename();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/tree/folders`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
folderId: draft.folderId,
name,
}),
});
const body = (await response.json()) as WorkspaceFoldersResponse;
if (!response.ok) {
throw new Error(body.message || "Failed to rename project tree folder.");
}
setPersistedFolders(readPersistedWorkspaceFolders(body));
setPendingFolderRename(null);
setPendingFolderRenameName("");
const previousFolderId = body.data?.previousFolderId;
const renamedFolderId = body.data?.renamedFolder?.id;
if (previousFolderId && renamedFolderId && previousFolderId !== renamedFolderId) {
setCollapsedFolderIds((current) =>
current.map((id) => (id === previousFolderId ? renamedFolderId : id)),
);
}
} catch (error) {
console.error(error);
}
};
const cancelPendingFolder = (): void => {
setPendingFolderDraft(null);
setPendingFolderName("");
};
const cancelPendingFolderRename = (): void => {
setPendingFolderRename(null);
setPendingFolderRenameName("");
};
const handleHeaderActionClick = (actionId: string): void => {
switch (actionId) {
case "toggle-workspace-folders":
toggleAllFolders();
return;
case "search-workspace":
default:
return;
}
};
const handleNodePointerDown = (event: PointerEvent, nodeId: string): void => {
if (event.button !== 0 || pendingFolderDraft()) {
return;
}
clearLongPressTimer();
longPressTimer = window.setTimeout(() => {
suppressTreeClickTemporarily();
setDragState({ draggedNodeId: nodeId, dropTarget: null });
}, LONG_PRESS_MS);
};
const handleNodePointerMove = (
event: PointerEvent,
parentId: string | null,
index: number,
node: WorkspaceTreeNode,
): void => {
const nextDragState = dragState();
if (!nextDragState || nextDragState.draggedNodeId === getWorkspaceTreeNodeId(node)) {
return;
}
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
setDragState({
...nextDragState,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: workspaceTreeAdapter,
}),
});
};
const handleContextActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
switch (action.id) {
case "new-folder":
switch (target.kind) {
case "workspace":
case "home":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findTreeNodeDepth(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0) + 1);
return;
case "settings":
case "item":
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(workspaceTreeNodes(), target.id, workspaceTreeAdapter) ?? 0);
}
return;
default:
return;
}
};
return (
<>
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
[styles.sidebarDragMode]: !!dragState(),
}}
aria-label="Left workspace sidebar"
data-ui="workspace-sidebar"
data-collapsed={props.collapsed ? "true" : "false"}
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget);
}}
>
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
data-slot="workspace-sidebar-header"
data-drawer-open={isProjectDrawerOpen() ? "true" : "false"}
>
<div class={styles.headerActions} data-slot="workspace-sidebar-header-actions">
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
data-slot="workspace-sidebar-rail-toggle"
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const label =
action.id === "toggle-workspace-folders"
? workspaceFolderToggleLabel()
: action.label;
const Icon =
action.id === "toggle-workspace-folders"
? areAllFoldersCollapsed()
? UnfoldVertical
: ListCollapse
: action.icon;
return (
<button
type="button"
class={styles.headerActionButton}
aria-label={label}
title={label}
data-slot="workspace-sidebar-header-action"
data-action-id={action.id}
disabled={action.id === "toggle-workspace-folders" && totalFolderCount() === 0}
onClick={(): void => handleHeaderActionClick(action.id)}
>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</div>
<div class={styles.headerControls} data-slot="workspace-sidebar-header-controls">
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
data-slot="workspace-sidebar-section"
>
<Show when={!props.collapsed}>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller} data-slot="workspace-sidebar-nav-scroller">
<ul class={styles.navList} role="list" data-slot="workspace-static-list">
<For each={workspaceStaticItems}>
{(item): JSX.Element => (
<WorkspaceHomeEntry
item={item}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
)}
</For>
</ul>
<Show when={!props.collapsed}>
<div class={styles.treeSectionLabel}>Items</div>
</Show>
<div data-slot="workspace-tree-root">
<WorkspaceTreeBranch
nodes={workspaceTreeNodes()}
parentId={null}
isFolderCollapsed={isFolderCollapsed}
onToggleFolder={toggleFolder}
pendingFolderDraft={pendingFolderDraft()}
pendingFolderName={pendingFolderName()}
onPendingFolderNameChange={setPendingFolderName}
onSubmitPendingFolder={submitPendingFolder}
onCancelPendingFolder={cancelPendingFolder}
pendingFolderRename={pendingFolderRename()}
pendingFolderRenameName={pendingFolderRenameName()}
onPendingFolderRenameChange={setPendingFolderRenameName}
onSubmitPendingFolderRename={submitPendingFolderRename}
onCancelPendingFolderRename={cancelPendingFolderRename}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
dragState={dragState()}
isTreeClickSuppressed={suppressNextTreeClick}
/>
</div>
</div>
</div>
</aside>
<WorkspaceContextMenu
target={contextMenu.menuState()?.target ?? null}
position={(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
})()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</>
);
};
@@ -1,352 +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;
};
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 normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: 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;
label: string;
kind: "folder";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type WorkspaceItemNode = {
id: 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: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ 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: `duplicate-${actionPrefix}`, label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ 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,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,9 @@
import { useNavigate } from "@solidjs/router";
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 { getWorkspaceSurfaceRoute } from "../../app-shell/data/workspace-routes";
import { profileMenuSections } from "../../app-shell/data/shell.data";
import styles from "./ProfileMenu.module.scss"; import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = { type ProfileMenuProps = {
@@ -14,8 +16,17 @@ type ProfileMenuProps = {
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => { export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover"; const variant = props.variant ?? "popover";
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const navigate = useNavigate();
const activeUserProfile = () => appShellData.activeUserProfile(); const activeUserProfile = () => appShellData.activeUserProfile();
const handleSelect = (actionId: string): void => {
if (actionId === "account-settings" || actionId === "security" || actionId === "theme-preferences") {
navigate(getWorkspaceSurfaceRoute("settings"));
}
props.onSelect();
};
return ( return (
<div <div
id={props.id} id={props.id}
@@ -61,11 +72,11 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
[styles.item]: true, [styles.item]: true,
[styles.itemDanger]: item.tone === "danger", [styles.itemDanger]: item.tone === "danger",
}} }}
data-slot="profile-action" data-slot="profile-action"
data-action-id={item.id} data-action-id={item.id}
data-tone={item.tone ?? "default"} data-tone={item.tone ?? "default"}
onClick={props.onSelect} onClick={() => handleSelect(item.id)}
> >
<span class={styles.itemIcon} aria-hidden="true"> <span class={styles.itemIcon} aria-hidden="true">
<Icon size={16} strokeWidth={2} /> <Icon size={16} strokeWidth={2} />
</span> </span>
@@ -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";

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