Compare commits

...

24 Commits

Author SHA1 Message Date
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
MangoPig dcf181d640 Fix: preserve folder label casing 2026-06-24 17:24:59 +01:00
MangoPig 1a8556df68 Merge branch 'Feature/Renaming-Folders' 2026-06-24 17:07:27 +01:00
MangoPig a5f0c41cba Feat: add persisted folder rename 2026-06-24 17:07:08 +01:00
MangoPig 268093d223 Merge branch 'Fix/Backend/Project-Item-Deletion' 2026-06-24 16:15:49 +01:00
MangoPig c64a7b8d44 Fix: add persisted project folder deletion 2026-06-24 16:15:30 +01:00
MangoPig 0b368b09fa Merge branch 'Refactor/Frontend/Nav-DragAndDrop-Logic' 2026-06-24 02:11:23 +01:00
MangoPig 212dd1c435 Refactor: share nav drag and drop logic 2026-06-24 02:11:02 +01:00
MangoPig 69af324b1b Merge branch 'Fix/Project-Tree-Items-Persistence' 2026-06-24 01:29:40 +01:00
MangoPig 5758074f6f Feat: persist project tree hierarchy 2026-06-24 01:28:50 +01:00
MangoPig 5b9e14b442 Merge branch 'Fix/Backend/Project-Folder-Persistence' 2026-06-22 14:15:34 +01:00
MangoPig 618e3e84be Fix: persist project folder hierarchy 2026-06-22 14:14:13 +01:00
MangoPig 2ff7fbd9e7 Merge branch 'Fix/Backend/Posix-Hierarchy-Fix' 2026-06-22 11:14:08 +01:00
MangoPig 8a94d83e7e Fix: align POSIX hierarchy contract 2026-06-22 11:13:25 +01:00
MangoPig 07590f1c4f Merge branch 'Features/Backend/Posix-DB-Projection' 2026-06-21 22:03:43 +01:00
MangoPig 3c7a73853d Feat: add POSIX DB projection 2026-06-21 22:02:59 +01:00
MangoPig 9b4f1ce197 Merge branch 'Features/Backend/Posix-Lite-Persistence' 2026-06-21 21:03:38 +01:00
MangoPig 5735e3008d Feat: add POSIX-lite bootstrap foundation 2026-06-21 21:02:59 +01:00
MangoPig 626ae02df0 Docs: Update TODO roadmap 2026-06-21 15:43:04 +01:00
29 changed files with 5387 additions and 910 deletions
+2
View File
@@ -27,3 +27,5 @@ tmp/
bin/ bin/
.cgcignore .cgcignore
POSIX/
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"log"
"os"
"moku-backend/internal/config"
"moku-backend/internal/database"
"moku-backend/internal/posixproj"
)
func main() {
command := "rebuild"
if len(os.Args) > 1 {
command = os.Args[1]
}
switch command {
case "rebuild":
if err := rebuildProjection(context.Background()); err != nil {
log.Fatalf("rebuild POSIX projection: %v", err)
}
default:
log.Fatalf("unsupported posix command %q (supported: rebuild)", command)
}
}
func rebuildProjection(ctx context.Context) error {
cfg := config.Load()
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
return fmt.Errorf("connect database: %w", err)
}
defer db.Close()
summary, err := posixproj.NewProjector(db, cfg.POSIXRoot).RebuildWithSummary(ctx)
if err != nil {
return err
}
fmt.Printf(
"POSIX projection rebuilt from %s\n total nodes: %d\n directories: %d\n files: %d\n",
cfg.POSIXRoot,
summary.TotalNodes,
summary.DirectoryCount,
summary.FileCount,
)
return nil
}
@@ -0,0 +1,45 @@
-- +goose Up
CREATE TYPE posix_node_kind AS ENUM ('directory', 'file');
CREATE TABLE IF NOT EXISTS posix_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT NOT NULL UNIQUE,
parent_path TEXT,
name TEXT NOT NULL,
depth INTEGER NOT NULL,
node_kind posix_node_kind NOT NULL,
logical_type TEXT NOT NULL DEFAULT 'generic',
file_role TEXT,
resource_id TEXT,
resource_name TEXT,
resource_slug TEXT,
installation_id TEXT,
organization_id TEXT,
organization_slug TEXT,
department_slug TEXT,
team_slug TEXT,
project_slug TEXT,
personal_slug TEXT,
content_json JSONB,
size_bytes BIGINT NOT NULL DEFAULT 0,
checksum TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_parent_path ON posix_nodes (parent_path);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_logical_type ON posix_nodes (logical_type);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_project_slug ON posix_nodes (project_slug);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_department_slug ON posix_nodes (department_slug);
CREATE INDEX IF NOT EXISTS idx_posix_nodes_team_slug ON posix_nodes (team_slug);
-- +goose Down
DROP INDEX IF EXISTS idx_posix_nodes_team_slug;
DROP INDEX IF EXISTS idx_posix_nodes_department_slug;
DROP INDEX IF EXISTS idx_posix_nodes_project_slug;
DROP INDEX IF EXISTS idx_posix_nodes_logical_type;
DROP INDEX IF EXISTS idx_posix_nodes_parent_path;
DROP TABLE IF EXISTS posix_nodes;
DROP TYPE IF EXISTS posix_node_kind;
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
package bootstrap
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
rootPath := filepath.Join(t.TempDir(), "POSIX")
t.Setenv("POSIX_ROOT", rootPath)
if _, err := os.Stat(rootPath); !os.IsNotExist(err) {
t.Fatalf("expected isolated POSIX root to start absent, got err=%v", err)
}
service := NewService(nil, os.Getenv("POSIX_ROOT"))
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)
}
requiredPaths := []string{
filepath.Join(rootPath, "settings.json"),
filepath.Join(rootPath, "layout.json"),
filepath.Join(rootPath, "catalog", "packs"),
filepath.Join(rootPath, "catalog", "standalone"),
filepath.Join(rootPath, "departments", "department-primary-department", "settings.json"),
filepath.Join(rootPath, "departments", "department-primary-department", "users.json"),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "settings.json"),
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "users.json"),
filepath.Join(rootPath, "projects", "project-primary-project", "settings.json"),
filepath.Join(rootPath, "projects", "project-primary-project", "home.json"),
filepath.Join(rootPath, "projects", "project-primary-project", "acl.json"),
filepath.Join(rootPath, "projects", "project-primary-project", "children"),
filepath.Join(rootPath, "projects", "project-primary-project", "tree"),
filepath.Join(rootPath, "users", "settings.json"),
filepath.Join(rootPath, "users", "data.json"),
filepath.Join(rootPath, "users", "personals"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "settings.json"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "layout.json"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "home.json"),
filepath.Join(rootPath, "users", "personals", "personal-ronald", "tree"),
}
for _, path := range requiredPaths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
settingsPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "settings.json"))
installationPayload, ok := settingsPayload["installation"].(map[string]any)
if !ok {
t.Fatalf("settings.json missing installation object: %#v", settingsPayload)
}
if installationPayload["name"] != "MangoPig" {
t.Fatalf("expected installation name MangoPig, got %#v", installationPayload["name"])
}
if installationPayload["isBootstrapped"] != true {
t.Fatalf("expected installation to be bootstrapped, got %#v", installationPayload["isBootstrapped"])
}
layoutPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "layout.json"))
homePayload, ok := layoutPayload["home"].(map[string]any)
if !ok {
t.Fatalf("layout.json missing home object: %#v", layoutPayload)
}
if homePayload["defaultProjectSlug"] != "primary-project" {
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"))
if projectSettings["type"] != "project" {
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"))
if projectACL["inherits"] != true {
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"))
if usersSettings["primaryAdminId"] != "admin-1" {
t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"])
}
personalSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", "settings.json"))
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"])
}
personalHome := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "personals", "personal-ronald", "home.json"))
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) {
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)
}
createdPath, createdSlug, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err)
}
if createdPath != "projects/project-primary-project/children/folder-design-system" {
t.Fatalf("unexpected created path: %s", createdPath)
}
if createdSlug != "design-system" {
t.Fatalf("unexpected created slug: %s", createdSlug)
}
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "children", "folder-design-system")
for _, path := range []string{
filepath.Join(createdFolderPath, "folder.json"),
filepath.Join(createdFolderPath, "acl.json"),
filepath.Join(createdFolderPath, "children"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(createdFolderPath, "folder.json"))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected created folder to have stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Design System" {
t.Fatalf("expected folder name Design System, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "design-system" {
t.Fatalf("expected folder slug design-system, got %#v", folderPayload["slug"])
}
nestedPath, nestedSlug, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err)
}
if nestedPath != "projects/project-primary-project/children/folder-design-system/children/folder-research" {
t.Fatalf("unexpected nested path: %s", nestedPath)
}
if nestedSlug != "research" {
t.Fatalf("unexpected nested slug: %s", nestedSlug)
}
}
func TestCreateProjectTreeFolderOnDiskCreatesExpectedFolderShape(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)
}
createdPath, createdSlug, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err)
}
if createdPath != "projects/project-primary-project/tree/folder-docs" {
t.Fatalf("unexpected created path: %s", createdPath)
}
if createdSlug != "docs" {
t.Fatalf("unexpected created slug: %s", createdSlug)
}
createdFolderPath := filepath.Join(rootPath, "projects", "project-primary-project", "tree", "folder-docs")
for _, path := range []string{
filepath.Join(createdFolderPath, "folder.json"),
filepath.Join(createdFolderPath, "acl.json"),
filepath.Join(createdFolderPath, "children"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected path to exist %s: %v", path, err)
}
}
nestedPath, nestedSlug, err := service.createProjectTreeFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk nested folder: %v", err)
}
if nestedPath != "projects/project-primary-project/tree/folder-docs/children/folder-research" {
t.Fatalf("unexpected nested path: %s", nestedPath)
}
if nestedSlug != "research" {
t.Fatalf("unexpected nested slug: %s", nestedSlug)
}
}
func TestRenameProjectHierarchyFolderOnDiskRenamesFolderShape(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)
}
createdPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", "", "Design System")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk root folder: %v", err)
}
nestedPath, _, err := service.createProjectHierarchyFolderOnDisk("primary-project", createdPath, "Research")
if err != nil {
t.Fatalf("createProjectHierarchyFolderOnDisk nested folder: %v", err)
}
previousPath, renamedPath, err := service.renameProjectHierarchyFolderOnDisk("primary-project", createdPath, "Platform Design")
if err != nil {
t.Fatalf("renameProjectHierarchyFolderOnDisk: %v", err)
}
if previousPath != createdPath {
t.Fatalf("expected previous path %s, got %s", createdPath, previousPath)
}
if renamedPath != "projects/project-primary-project/children/folder-platform-design" {
t.Fatalf("unexpected renamed path: %s", renamedPath)
}
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(createdPath))); !os.IsNotExist(err) {
t.Fatalf("expected previous folder path to be gone, got err=%v", err)
}
renamedFolderPath := filepath.Join(rootPath, filepath.FromSlash(renamedPath))
if _, err := os.Stat(filepath.Join(renamedFolderPath, "children", filepath.Base(nestedPath))); err != nil {
t.Fatalf("expected nested child folder to move with renamed parent: %v", err)
}
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(renamedFolderPath, "folder.json"))
if strings.TrimSpace(asStringForTest(folderPayload["id"])) == "" {
t.Fatalf("expected renamed folder to preserve stable id, got %#v", folderPayload["id"])
}
if folderPayload["name"] != "Platform Design" {
t.Fatalf("expected renamed folder name Platform Design, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "platform-design" {
t.Fatalf("expected renamed folder slug platform-design, got %#v", folderPayload["slug"])
}
if folderPayload["type"] != "folder" {
t.Fatalf("expected renamed folder type folder, got %#v", folderPayload["type"])
}
}
func TestRenameProjectTreeFolderOnDiskRenamesFolderShape(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)
}
createdPath, _, err := service.createProjectTreeFolderOnDisk("primary-project", "", "Docs")
if err != nil {
t.Fatalf("createProjectTreeFolderOnDisk root folder: %v", err)
}
previousPath, renamedPath, err := service.renameProjectTreeFolderOnDisk("primary-project", createdPath, "Specifications")
if err != nil {
t.Fatalf("renameProjectTreeFolderOnDisk: %v", err)
}
if previousPath != createdPath {
t.Fatalf("expected previous path %s, got %s", createdPath, previousPath)
}
if renamedPath != "projects/project-primary-project/tree/folder-specifications" {
t.Fatalf("unexpected renamed path: %s", renamedPath)
}
folderPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(renamedPath), "folder.json"))
if folderPayload["name"] != "Specifications" {
t.Fatalf("expected renamed folder name Specifications, got %#v", folderPayload["name"])
}
if folderPayload["slug"] != "specifications" {
t.Fatalf("expected renamed folder slug specifications, got %#v", folderPayload["slug"])
}
}
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 := readJSONFileForTest[map[string]any](t, filepath.Join(movedFolderPath, "folder.json"))
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 := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, filepath.FromSlash(movedPath), "folder.json"))
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) {
rows := []projectHierarchyFolderRow{
{ID: "folder-design-id", Path: "projects/project-primary-project/children/folder-design", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Design"},
{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"},
{ID: "folder-ops-id", Path: "projects/project-primary-project/children/folder-ops", ParentPath: projectHierarchyRootPath("primary-project"), Label: "Ops"},
}
folders := buildProjectHierarchyFolderTree(rows, projectHierarchyRootPath("primary-project"))
if len(folders) != 2 {
t.Fatalf("expected 2 top-level folders, got %d", len(folders))
}
if folders[0].Label != "Design" || folders[1].Label != "Ops" {
t.Fatalf("unexpected top-level folder labels: %#v", folders)
}
if len(folders[0].Children) != 1 || folders[0].Children[0].Label != "Research" {
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 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 readJSONFileForTest[T any](t *testing.T, path string) T {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var payload T
if err := json.Unmarshal(data, &payload); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
return payload
}
+2
View File
@@ -17,6 +17,7 @@ type Config struct {
APIPort string APIPort string
PostgresURL string PostgresURL string
ValkeyURL string ValkeyURL string
POSIXRoot string
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
} }
@@ -29,6 +30,7 @@ func Load() *Config {
APIPort: getEnv("BACKEND_API_PORT", "8081"), APIPort: getEnv("BACKEND_API_PORT", "8081"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"), PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"), ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
POSIXRoot: getEnv("POSIX_ROOT", "../POSIX"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second), ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
} }
} }
@@ -341,7 +341,7 @@ func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *h
} }
func (routes apiRoutes) bootstrapService() *bootstrapservice.Service { func (routes apiRoutes) bootstrapService() *bootstrapservice.Service {
return bootstrapservice.NewService(routes.cfg.Database) return bootstrapservice.NewService(routes.cfg.Database, routes.cfg.Config.POSIXRoot)
} }
func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) { func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) {
@@ -0,0 +1,485 @@
package httpx
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
bootstrapservice "moku-backend/internal/bootstrap"
"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"`
}
type moveProjectFolderRequest struct {
FolderID string `json:"folderId"`
FolderNodeID string `json:"folderNodeId"`
ParentFolderID string `json:"parentFolderId"`
ParentNodeID string `json:"parentNodeId"`
TargetIndex int `json:"targetIndex"`
}
func (routes apiRoutes) handleProjectFolders(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
}
folders, err := routes.bootstrapService().GetProjectHierarchyFolders(r.Context(), projectID)
if err != nil {
routes.writeProjectFolderError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"folders": folders,
},
"meta": map[string]any{
"resource": "project-folders",
},
})
}
func (routes apiRoutes) handleCreateProjectFolder(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 := decodeProjectFolderRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().CreateProjectFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID,
ParentFolderID: payload.ParentFolderID,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectFolder(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 := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID,
FolderID: payload.FolderID,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleRenameProjectFolder(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 := decodeRenameProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderID = strings.TrimSpace(payload.FolderID)
payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().RenameProjectFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID,
FolderID: payload.FolderID,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "rename")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-folder-rename",
"persisted": true,
},
})
}
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.FolderID = strings.TrimSpace(payload.FolderID)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID)
if payload.FolderID == "" {
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,
FolderID: payload.FolderID,
FolderNodeID: payload.FolderNodeID,
ParentFolderID: payload.ParentFolderID,
ParentNodeID: payload.ParentNodeID,
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) {
projectID := strings.TrimSpace(chi.URLParam(r, "projectId"))
if projectID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Project ID is required.")
return
}
folders, err := routes.bootstrapService().GetProjectTreeFolders(r.Context(), projectID)
if err != nil {
routes.writeProjectFolderError(w, r, err, "load")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"projectId": projectID,
"folders": folders,
},
"meta": map[string]any{
"resource": "project-tree-folders",
},
})
}
func (routes apiRoutes) handleCreateProjectTreeFolder(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 := decodeProjectFolderRequest(w, r)
if !ok {
return
}
payload.Name = strings.TrimSpace(payload.Name)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID)
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().CreateProjectTreeFolder(r.Context(), bootstrapservice.CreateProjectFolderInput{
ProjectID: projectID,
ParentFolderID: payload.ParentFolderID,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "persist")
return
}
WriteJSON(w, http.StatusCreated, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-create",
"persisted": true,
},
})
}
func (routes apiRoutes) handleDeleteProjectTreeFolder(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 := decodeDeleteProjectFolderRequest(r)
if strings.TrimSpace(payload.FolderID) == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
result, err := routes.bootstrapService().DeleteProjectTreeFolder(r.Context(), bootstrapservice.DeleteProjectFolderInput{
ProjectID: projectID,
FolderID: payload.FolderID,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "delete")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-delete",
"persisted": true,
},
})
}
func (routes apiRoutes) handleRenameProjectTreeFolder(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 := decodeRenameProjectFolderRequest(w, r)
if !ok {
return
}
payload.FolderID = strings.TrimSpace(payload.FolderID)
payload.Name = strings.TrimSpace(payload.Name)
if payload.FolderID == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder ID is required.")
return
}
if payload.Name == "" {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Folder name is required.")
return
}
result, err := routes.bootstrapService().RenameProjectTreeFolder(r.Context(), bootstrapservice.RenameProjectFolderInput{
ProjectID: projectID,
FolderID: payload.FolderID,
Name: payload.Name,
})
if err != nil {
routes.writeProjectFolderError(w, r, err, "rename")
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": result,
"meta": map[string]any{
"resource": "project-tree-folder-rename",
"persisted": true,
},
})
}
func (routes apiRoutes) handleMoveProjectTreeFolder(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.FolderID = strings.TrimSpace(payload.FolderID)
payload.FolderNodeID = strings.TrimSpace(payload.FolderNodeID)
payload.ParentFolderID = strings.TrimSpace(payload.ParentFolderID)
payload.ParentNodeID = strings.TrimSpace(payload.ParentNodeID)
if payload.FolderID == "" {
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,
FolderID: payload.FolderID,
FolderNodeID: payload.FolderNodeID,
ParentFolderID: payload.ParentFolderID,
ParentNodeID: payload.ParentNodeID,
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 (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 decodeMoveProjectFolderRequest(w http.ResponseWriter, r *http.Request) (moveProjectFolderRequest, bool) {
var payload moveProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeDeleteProjectFolderRequest(r *http.Request) deleteProjectFolderRequest {
return deleteProjectFolderRequest{
FolderID: strings.TrimSpace(r.URL.Query().Get("folderId")),
}
}
func decodeRenameProjectFolderRequest(w http.ResponseWriter, r *http.Request) (renameProjectFolderRequest, bool) {
var payload renameProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
func decodeProjectFolderRequest(w http.ResponseWriter, r *http.Request) (createProjectFolderRequest, bool) {
var payload createProjectFolderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
if errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body is required and must be valid JSON.")
return payload, false
}
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must be valid JSON.")
return payload, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_json", "The request body must contain a single JSON object.")
return payload, false
}
return payload, true
}
+12
View File
@@ -33,6 +33,18 @@ func (routes apiRoutes) Register(router chi.Router) {
apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/app-shell", routes.handleAppShellState)
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) {
projectRouter.Get("/folders", routes.handleProjectFolders)
projectRouter.Post("/folders", routes.handleCreateProjectFolder)
projectRouter.Patch("/folders", routes.handleRenameProjectFolder)
projectRouter.Patch("/folders/move", routes.handleMoveProjectFolder)
projectRouter.Delete("/folders", routes.handleDeleteProjectFolder)
projectRouter.Get("/tree/folders", routes.handleProjectTreeFolders)
projectRouter.Post("/tree/folders", routes.handleCreateProjectTreeFolder)
projectRouter.Patch("/tree/folders", routes.handleRenameProjectTreeFolder)
projectRouter.Patch("/tree/folders/move", routes.handleMoveProjectTreeFolder)
projectRouter.Delete("/tree/folders", routes.handleDeleteProjectTreeFolder)
})
if routes.cfg.Config.IsDevelopment() { if routes.cfg.Config.IsDevelopment() {
apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset) apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset)
+553
View File
@@ -0,0 +1,553 @@
package posixproj
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"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 {
return &Projector{db: db, root: strings.TrimSpace(root)}
}
func (projector *Projector) Rebuild(ctx context.Context) error {
_, err := projector.RebuildWithSummary(ctx)
return err
}
func (projector *Projector) RebuildWithSummary(ctx context.Context) (RebuildSummary, error) {
if projector == nil || projector.db == nil || projector.db.Pool == nil {
return RebuildSummary{}, nil
}
nodes, err := ScanRoot(projector.root)
if err != nil {
return RebuildSummary{}, err
}
summary := summarizeNodes(nodes)
tx, err := projector.db.Pool.Begin(ctx)
if err != nil {
return RebuildSummary{}, err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `DELETE FROM posix_nodes;`); err != nil {
return RebuildSummary{}, fmt.Errorf("clear posix_nodes: %w", err)
}
for _, node := range nodes {
if _, err := tx.Exec(ctx, `
INSERT INTO posix_nodes (
path,
parent_path,
name,
depth,
node_kind,
logical_type,
file_role,
resource_id,
resource_name,
resource_slug,
installation_id,
organization_id,
organization_slug,
department_slug,
team_slug,
project_slug,
personal_slug,
content_json,
size_bytes,
checksum
) VALUES (
$1, $2, $3, $4, $5::posix_node_kind, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19, $20
);
`,
node.Path,
node.ParentPath,
node.Name,
node.Depth,
string(node.NodeKind),
node.LogicalType,
node.FileRole,
node.ResourceID,
node.ResourceName,
node.ResourceSlug,
node.InstallationID,
node.OrganizationID,
node.OrganizationSlug,
node.DepartmentSlug,
node.TeamSlug,
node.ProjectSlug,
node.PersonalSlug,
node.ContentJSON,
node.SizeBytes,
node.Checksum,
); err != nil {
return RebuildSummary{}, fmt.Errorf("insert posix node %s: %w", node.Path, err)
}
}
if err := tx.Commit(ctx); err != nil {
return RebuildSummary{}, err
}
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,232 @@
package posixproj
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestScanRootBuildsProjectedNodesFromBootstrapShape(t *testing.T) {
root := filepath.Join(t.TempDir(), "POSIX")
mustMkdirAll(t, filepath.Join(root, "catalog", "packs"))
mustMkdirAll(t, filepath.Join(root, "catalog", "standalone"))
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", "tree"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "children"))
mustMkdirAll(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap"))
mustMkdirAll(t, filepath.Join(root, "users", "personals"))
mustWriteJSON(t, filepath.Join(root, "settings.json"), map[string]any{
"installation": map[string]any{
"id": "installation-1",
"name": "MangoPig",
"isBootstrapped": true,
},
"organization": map[string]any{
"id": "org-1",
"name": "Primary Organization",
"slug": "primary-organization",
},
})
mustWriteJSON(t, filepath.Join(root, "layout.json"), map[string]any{
"type": "tenant-layout",
"home": map[string]any{"defaultProjectSlug": "primary-project"},
})
mustWriteJSON(t, filepath.Join(root, "departments", "department-primary-department", "settings.json"), map[string]any{
"id": "dept-1",
"name": "Primary Department",
"slug": "primary-department",
"type": "department",
})
mustWriteJSON(t, filepath.Join(root, "departments", "department-primary-department", "users.json"), map[string]any{
"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{
"id": "team-1",
"name": "Primary Team",
"slug": "primary-team",
"type": "team",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "settings.json"), map[string]any{
"id": "project-1",
"name": "Primary Project",
"slug": "primary-project",
"type": "project",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "home.json"), map[string]any{
"type": "project-home",
"project": "primary-project",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "acl.json"), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "folder.json"), map[string]any{
"name": "Design",
"slug": "design",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "acl.json"), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "settings.json"), map[string]any{
"id": "project-2",
"name": "Web Project",
"slug": "web",
"type": "project",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "home.json"), map[string]any{
"type": "project-home",
"project": "web",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "children", "folder-design", "children", "project-web", "acl.json"), map[string]any{
"inherits": true,
"rules": []any{},
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "folder.json"), map[string]any{
"name": "Docs",
"slug": "docs",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "item.json"), map[string]any{
"id": "item-1",
"name": "Roadmap",
"slug": "roadmap",
"type": "board",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "schema.json"), map[string]any{
"type": "object",
})
mustWriteJSON(t, filepath.Join(root, "projects", "project-primary-project", "tree", "folder-docs", "item-roadmap", "data.json"), map[string]any{
"title": "Roadmap",
})
mustWriteJSON(t, filepath.Join(root, "users", "settings.json"), map[string]any{
"primaryAdminId": "admin-1",
})
mustWriteJSON(t, filepath.Join(root, "users", "data.json"), map[string]any{
"users": []map[string]any{{"id": "admin-1", "email": "ronald@example.com"}},
})
nodes, err := ScanRoot(root)
if err != nil {
t.Fatalf("ScanRoot() error = %v", err)
}
index := make(map[string]Node, len(nodes))
for _, node := range nodes {
index[node.Path] = node
}
rootNode, ok := index[rootProjectionPath]
if !ok {
t.Fatalf("expected synthetic root node")
}
if rootNode.LogicalType != "tenant_root" {
t.Fatalf("expected root logical type tenant_root, got %q", rootNode.LogicalType)
}
if rootNode.OrganizationSlug != "primary-organization" {
t.Fatalf("expected root organization slug primary-organization, got %q", rootNode.OrganizationSlug)
}
tenantSettings := index["settings.json"]
if tenantSettings.LogicalType != "tenant" || tenantSettings.FileRole != "settings" {
t.Fatalf("unexpected tenant settings classification: %#v", tenantSettings)
}
if tenantSettings.InstallationID != "installation-1" {
t.Fatalf("expected installation id installation-1, got %q", tenantSettings.InstallationID)
}
deptSettings := index["departments/department-primary-department/settings.json"]
if deptSettings.DepartmentSlug != "primary-department" {
t.Fatalf("expected department slug primary-department, got %q", deptSettings.DepartmentSlug)
}
if deptSettings.ResourceID != "dept-1" {
t.Fatalf("expected department resource id dept-1, got %q", deptSettings.ResourceID)
}
teamSettings := index["departments/department-primary-department/teams/team-primary-team/settings.json"]
if teamSettings.TeamSlug != "primary-team" {
t.Fatalf("expected team slug primary-team, got %q", teamSettings.TeamSlug)
}
projectSettings := index["projects/project-primary-project/settings.json"]
if projectSettings.ProjectSlug != "primary-project" {
t.Fatalf("expected project slug primary-project, got %q", projectSettings.ProjectSlug)
}
if projectSettings.ResourceName != "Primary Project" {
t.Fatalf("expected project resource name Primary Project, got %q", projectSettings.ResourceName)
}
projectTree := index["projects/project-primary-project/tree"]
if projectTree.LogicalType != "project_tree" || projectTree.NodeKind != NodeKindDirectory {
t.Fatalf("unexpected project tree node: %#v", projectTree)
}
projectChildren := index["projects/project-primary-project/children"]
if projectChildren.LogicalType != "project_children" || projectChildren.NodeKind != NodeKindDirectory {
t.Fatalf("unexpected project children node: %#v", projectChildren)
}
hierarchyFolder := index["projects/project-primary-project/children/folder-design"]
if hierarchyFolder.LogicalType != "hierarchy_folder" || hierarchyFolder.ProjectSlug != "primary-project" {
t.Fatalf("unexpected hierarchy folder node: %#v", hierarchyFolder)
}
hierarchyFolderACL := index["projects/project-primary-project/children/folder-design/acl.json"]
if hierarchyFolderACL.LogicalType != "hierarchy_folder" || hierarchyFolderACL.FileRole != "acl" {
t.Fatalf("unexpected hierarchy folder acl classification: %#v", hierarchyFolderACL)
}
childProjectSettings := index["projects/project-primary-project/children/folder-design/children/project-web/settings.json"]
if childProjectSettings.LogicalType != "project" || childProjectSettings.ProjectSlug != "web" {
t.Fatalf("unexpected child project classification: %#v", childProjectSettings)
}
treeFolder := index["projects/project-primary-project/tree/folder-docs"]
if treeFolder.LogicalType != "hierarchy_folder" || treeFolder.ProjectSlug != "primary-project" {
t.Fatalf("unexpected tree folder node: %#v", treeFolder)
}
treeFolderACL := index["projects/project-primary-project/tree/folder-docs/folder.json"]
if treeFolderACL.LogicalType != "hierarchy_folder" || treeFolderACL.FileRole != "folder" {
t.Fatalf("unexpected tree folder file classification: %#v", treeFolderACL)
}
treeItem := index["projects/project-primary-project/tree/folder-docs/item-roadmap/item.json"]
if treeItem.LogicalType != "item" || treeItem.FileRole != "item" {
t.Fatalf("unexpected tree item classification: %#v", treeItem)
}
if treeItem.ResourceSlug != "roadmap" {
t.Fatalf("expected tree item resource slug roadmap, got %#v", treeItem.ResourceSlug)
}
usersData := index["users/data.json"]
if usersData.LogicalType != "users" || usersData.FileRole != "data" {
t.Fatalf("unexpected users data classification: %#v", usersData)
}
if usersData.Checksum == "" || usersData.SizeBytes == 0 {
t.Fatalf("expected users/data.json checksum and size to be populated: %#v", usersData)
}
}
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", path, err)
}
}
func mustWriteJSON(t *testing.T, path string, payload any) {
t.Helper()
bytes, err := json.MarshalIndent(payload, "", " ")
if err != nil {
t.Fatalf("MarshalIndent(%q) error = %v", path, err)
}
bytes = append(bytes, '\n')
if err := os.WriteFile(path, bytes, 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
+9 -5
View File
@@ -9,18 +9,22 @@ migrate-up:
migrate-down: migrate-down:
cd '{{backend_dir}}' && go run ./cmd/migrate down cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations and reapply from scratch. # Reset all embedded database migrations.
migrate-reset: migrate-reset:
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.
migrate-rebuild:
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:
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.
posix-rebuild:
cd '{{backend_dir}}' && go run ./cmd/posix rebuild
# Format backend Go source files. # Format backend Go source files.
fmt: fmt:
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...
+11
View File
@@ -0,0 +1,11 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
# Run the full backend test suite.
[default]
all:
cd '{{backend_dir}}' && go test ./...
# Run the isolated POSIX bootstrap smoke test.
posix-bootstrap:
cd '{{backend_dir}}' && go test ./internal/bootstrap -run TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot -count=1 -v
+1
View File
@@ -0,0 +1 @@
mod backend
+2
View File
@@ -6,6 +6,7 @@ x-backend-service: &backend-service
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
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -13,6 +14,7 @@ x-backend-service: &backend-service
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../Backend:/app - ../Backend:/app
- ../POSIX:/posix
- moku_work_backend_go_pkg:/go/pkg/mod - moku_work_backend_go_pkg:/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build - moku_work_backend_go_build:/root/.cache/go-build
+3
View File
@@ -5,11 +5,14 @@ x-backend-service: &backend-service
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
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
valkey: valkey:
condition: service_healthy condition: service_healthy
volumes:
- ../POSIX:/posix
services: services:
postgres: postgres:
+75
View File
@@ -0,0 +1,75 @@
# 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*)
``` markdown
Personal or Organization (server)/
├── settings.json
├── layout.json
├── catalog/
│ ├── packs/
│ │ └── pack-<slug>/
│ │ ├── manifest.json
│ │ └── entries/
│ │ └── app-<slug>/
│ │ └── manifest.json
│ └── standalone/
│ └── app-<slug>/
│ └── manifest.json
├── departments/
│ └── department-<slug>/
│ ├── settings.json
│ ├── users.json
│ └── teams/
│ └── team-<slug>/
│ ├── settings.json
│ └── users.json
├── projects/
│ └── project-<slug>/
│ ├── settings.json
│ ├── home.json
│ ├── acl.json
│ ├── children/
│ │ └── folder-<slug>/
│ │ ├── folder.json
│ │ ├── acl.json
│ │ └── children/
│ │ └── project-<slug>/
│ │ ├── settings.json
│ │ ├── home.json
│ │ ├── acl.json
│ │ ├── children/
│ │ └── tree/
│ └── tree/
│ ├── item-<slug>/
│ │ ├── item.json
│ │ ├── schema.json
│ │ └── data.json
│ └── folder-<slug>/
│ ├── folder.json
│ └── item-<slug>/
│ ├── item.json
│ ├── schema.json
│ └── data.json
└── users/
├── settings.json
├── data.json
└── personals/
└── personal-<slug>/
├── layout.json
├── settings.json
├── home.json
└── tree/
```
## File Responsibilities
- `settings.json` — 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.
- `home.json` — Home surface configuration, such as widgets, sections, and how they are arranged.
- `folder.json` — 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.
- `schema.json` — The structure expected by that item's data.
- `data.json` — The actual content or state data for that item.
- `manifest.json` — Catalog definition metadata, including versioning, description, and capabilities for reusable apps or entries.
- `users.json` — User membership or assignment data for departments and teams.
+114 -52
View File
@@ -4,87 +4,149 @@
### Version 0.1.0 ### Version 0.1.0
**Goal:** Barebone frontend with a real backend core. **Goal:** Finish the base application shell, auth, and platform foundations.
#### Architecture #### Architecture and Delivery
- [ ] Project-Structure - [x] Project-Structure
- [ ] Stack-Decisions - [x] Stack-Decisions
- [ ] Proxy - [x] Proxy
- [ ] Local-Prod-NGINX-Proxy - [x] Local-Dev-Vite-Proxy
- [ ] Static-Frontend-Serving - [x] Local-Prod-NGINX-Proxy
- [ ] First-Request-Web-Loader - [x] First-Request-Web-Loader
- [ ] Bootstrap-Document - [x] Bootstrap-Document
- [ ] Route-Intent-Handoff - [x] Route-Intent-Handoff
- [ ] Tiny-First-Paint-Budget - [x] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds - [x] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands - [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose - [x] Local-Dev-Docker-Compose
- [ ] Local-Prod-Just-Commands - [x] Local-Prod-Just-Commands
- [ ] Local-Prod-Docker-Compose - [x] Local-Prod-Docker-Compose
- [ ] Frontend-Production-Dockerfile - [x] Frontend-Production-Dockerfile
- [ ] Frontend-docker-bake - [x] Frontend-docker-bake
#### Backend #### Backend — Done Foundations
- [x] Bootstrap-Persistence
- [x] Installation-Step
- [x] Mode-Step
- [x] Admin-Step
- [x] Structure-Step
- [x] Bootstrap-State-Authority
- [x] Development-Bootstrap-Reset
- [x] Base-Schema
- [x] Installations
- [x] Users
- [x] User-Homes
- [x] Organizations
- [x] Departments
- [x] Teams
- [x] Projects
- [x] Workspaces
- [x] Membership-Tables
- [x] App-Shell-Read-API
- [x] App-Shell-State-Endpoint
- [x] Bootstrap-Read-Endpoints
- [x] Shell-Tree-Hydration
- [x] Web-Route-Scaffolds
- [x] Session-Endpoint-Scaffold
- [x] Bootstrap-Endpoint-Scaffold
- [x] Current-User-Endpoint-Scaffold
#### Backend — Remaining for 0.1.0
- [ ] Auth - [ ] Auth
- [ ] Session-Flow - [ ] Session-Flow
- [ ] Login-Logout-Foundation - [ ] Login-Logout-Foundation
- [ ] Authentication - [ ] Authentication
- [ ] User - [ ] Current-User-Implementation
- [ ] Base-Model - [ ] POSIX-Lite-File-Persistence-Foundation
- [ ] Mounted-Storage-Root-Config
- [ ] Project-Folder-Creation-On-Backend
- [ ] moku.project.json
- [ ] Item-Folder-Creation
- [ ] item.json
- [ ] schema.json
- [ ] data.json
- [ ] DB-To-Files-Write-Flow
- [ ] User-and-Workspace-Domain-Readiness
- [ ] Base-Workspace - [ ] Base-Workspace
- [ ] Folders-and-Subfolders - [ ] Boards
- [ ] Boards - [ ] Dashboard
- [ ] Dashboard
- [ ] Organization
- [ ] Base-Model
- [ ] Access-Rules-and-Membership
- [ ] Workspace
- [ ] Folders-and-Subfolders
- [ ] API - [ ] API
- [ ] Real-Organizations-Read-Endpoint
- [ ] Real-Workspaces-Read-Endpoint
- [ ] Tree-Mutation-Endpoints
- [ ] Project-Creation-Endpoint
#### Frontend #### Frontend — Done Foundations
- [x] Foundation - [x] Foundation
- [x] Typography - [x] Typography
- [x] Icons - [x] Icons
- [ ] App Shell - [x] App-Shell
- [x] Left-Rail
- [x] Top-Bar
- [x] Server-Dock
- [x] Department-Selector
- [x] Theme-Toggle
- [x] Notifications-Menu
- [x] Profile-Menu
- [x] Responsive-Shell
- [x] Collapsible-Shell
- [x] Mobile-Bottom-Nav
- [x] Mobile-Workspace-Browser
- [x] Mobile-Workspace-Views
- [x] Context-Menus
- [x] Workspace-Context-Menu
- [x] Project-Context-Menu
- [x] Bootstrap-Workspace-Home
- [x] Bootstrap-Wizard
- [x] Bootstrap-Step-Submission
- [x] App-Shell-Reload-After-Bootstrap
- [x] Project-Menu
- [x] Folders-and-Subfolders
- [x] Rooted-From-Department
- [x] Long-Press-Drag-and-Drop
- [x] Workspace-Tree
- [x] Folders-and-Subfolders
- [x] Long-Press-Drag-and-Drop
- [x] App-Shell-Hydration
#### Frontend — Remaining for 0.1.0
- [ ] Primitives - [ ] Primitives
- [ ] Button - [ ] Button
- [ ] IconButton - [ ] IconButton
- [ ] Input - [ ] Input
- [ ] Surface - [ ] Surface
- [ ] Nav-Bar
- [ ] Workspace-Switching - [ ] Workspace-Switching
- [ ] Workspace-Home - [ ] Real-Workspace-Home
- [ ] Real-Workspace-Tree-Hydration
- [ ] Create-Project-Flow
- [ ] Persist-Tree-Mutations
- [ ] Connect-Tree-Interactions-To-Backend-Data
### Version 0.2.0 ### Version 0.2.0
**Goal:** First real work surface. **Goal:** Build the plugin app system on top of the base platform. And core app plugins like calendar, board, docs and text channels
- [ ] Table
- [ ] CVA
- [ ] Storyboard
- [ ] Theme-System
- [ ] Theme-Registry
- [ ] Built-In-Theme-Presets
- [ ] Active-Theme-Persistence
- [ ] Theme-Switcher
- [ ] Theme-JSON-Upload
- [ ] Theme-JSON-Import-Validation
- [ ] Community-Theme-Readiness
### Version 0.3.0 ### Version 0.3.0
**Goal:** Documents and system hardening. **Goal:** Communications and Collaboration (Email System, Reminder System, and Live Collaboration on Documents)
- [ ] Document
- [ ] Accessibility-Rules
- [ ] Motion-Foundation
### Version 0.4.0 ### Version 0.4.0
- [ ] Gantt-Board **Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage + Per File Versioning
- [ ] Calendar
- [ ] Timeline ### Version 0.5.0
**Goal:** File Sharing and Per File Permissions
### Version 0.6.0
**Goal:** Git as a core plugin
### Version 0.7.0
**Goal:** Full Automation System (Extensive)
+1
View File
@@ -10,6 +10,7 @@ BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0 VALKEY_URL=redis://localhost:6379/0
POSIX_ROOT=../POSIX
VITE_API_BASE_URL=/v1 VITE_API_BASE_URL=/v1
@@ -1,3 +1,5 @@
@use "../shared/tree-nav" as treeNav;
.root { .root {
display: grid; display: grid;
--project-drawer-gap: var(--space-3); --project-drawer-gap: var(--space-3);
@@ -191,142 +193,126 @@
} }
.treeSectionLabel { .treeSectionLabel {
@include text-caption; @include treeNav.section-label;
margin: 0 0 var(--space-2); margin: 0;
padding: 0 var(--space-3); padding: 0 var(--space-3);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.treeList { .treeSectionHeader {
list-style: none; display: flex;
display: grid;
gap: var(--space-1);
padding: 0;
}
.treeEmptySlot {
min-height: calc(var(--control-size-lg) - var(--space-2));
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border-radius: var(--radius-lg);
border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent);
opacity: 0.35;
}
.treeInputRow {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--space-2); gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2)); margin-bottom: var(--space-2);
padding: var(--space-2) var(--space-3); padding-right: var(--space-1);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
} }
.treeInput { .treeControls {
width: 100%; display: inline-flex;
min-width: 0; align-items: center;
border: 0; gap: var(--space-1);
background: transparent; flex-wrap: wrap;
justify-content: flex-end;
}
.treeControlButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--control-size-md) - var(--space-1));
height: calc(var(--control-size-md) - var(--space-1));
@include text-caption;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 46%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 95%, transparent);
color: var(--color-text-muted);
transition:
border-color 160ms var(--easing-standard),
background 160ms var(--easing-standard),
color 160ms var(--easing-standard);
}
.treeControlButton:hover,
.treeControlButton:focus-visible {
border-color: color-mix(in srgb, var(--color-border-strong) 56%, transparent);
background: var(--color-surface-hover);
color: var(--color-text); color: var(--color-text);
font: inherit;
outline: none; outline: none;
} }
.treeInput::placeholder { .treeControlButton:disabled {
color: var(--color-text-muted); opacity: 0.45;
cursor: default;
}
.treeList {
@include treeNav.tree-list;
}
.treeEmptySlot {
@include treeNav.empty-slot;
}
.treeInputRow {
@include treeNav.input-row;
}
.treeInput {
@include treeNav.input;
} }
.treeItem { .treeItem {
width: 100%; @include treeNav.item;
min-width: 0;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-text-muted);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
text-align: left;
} }
.treeItem:hover, .treeItem:hover,
.treeItem:focus-visible { .treeItem:focus-visible {
background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); @include treeNav.item-hover;
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.treeItemFolder { .treeItemFolder {
color: var(--color-text); @include treeNav.item-folder;
} }
.treeItemDragging { .treeItemDragging {
opacity: 0.45; @include treeNav.item-dragging;
transform: scale(0.985);
box-shadow: none;
} }
.treeItemDropBefore { .treeItemDropBefore {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); @include treeNav.item-drop-before;
} }
.treeItemDropAfter { .treeItemDropAfter {
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); @include treeNav.item-drop-after;
} }
.treeItemDropInside { .treeItemDropInside {
border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); @include treeNav.item-drop-inside;
background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface));
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.folderChevron { .folderChevron {
color: var(--color-text-muted); @include treeNav.folder-chevron;
transition: transform 160ms var(--easing-standard);
} }
.folderChevronOpen { .folderChevronOpen {
transform: rotate(90deg); @include treeNav.folder-chevron-open;
} }
.treeItemActive { .treeItemActive {
border-color: var(--color-border); @include treeNav.item-active;
background: var(--color-surface);
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.icon { .icon {
color: inherit; @include treeNav.icon;
opacity: 0.85;
} }
.label { .label {
@include text-label; @include treeNav.label;
min-width: 0;
} }
.itemMeta { .itemMeta {
@include text-caption; @include treeNav.item-meta;
color: var(--color-text-muted);
} }
@media (max-width: 720px) { @media (max-width: 720px) {
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
@use "../shared/tree-nav" as treeNav;
.sidebar { .sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg); --sidebar-nav-item-min-height: var(--control-size-lg);
position: relative; position: relative;
@@ -117,56 +119,25 @@
} }
.treeSectionLabel { .treeSectionLabel {
@include text-caption; @include treeNav.section-label;
margin: var(--space-3) 0 var(--space-2); margin: var(--space-3) 0 var(--space-2);
padding: 0 var(--space-3); padding: 0 var(--space-3);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
} }
.treeList { .treeList {
list-style: none; @include treeNav.tree-list;
display: grid;
gap: var(--space-1);
padding: 0;
} }
.treeEmptySlot { .treeEmptySlot {
min-height: calc(var(--control-size-lg) - var(--space-2)); @include treeNav.empty-slot;
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border-radius: var(--radius-lg);
border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent);
opacity: 0.35;
} }
.treeInputRow { .treeInputRow {
width: 100%; @include treeNav.input-row;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
} }
.treeInput { .treeInput {
width: 100%; @include treeNav.input;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
font: inherit;
outline: none;
}
.treeInput::placeholder {
color: var(--color-text-muted);
} }
.navItem { .navItem {
@@ -184,74 +155,44 @@
} }
.treeItem { .treeItem {
width: 100%; @include treeNav.item;
min-width: 0;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-text-muted);
text-align: left;
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
} }
.treeItem:hover, .treeItem:hover,
.treeItem:focus-visible { .treeItem:focus-visible {
background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%); @include treeNav.item-hover;
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.treeItemFolder { .treeItemFolder {
color: var(--color-text); @include treeNav.item-folder;
} }
.treeItemDragging { .treeItemDragging {
opacity: 0.45; @include treeNav.item-dragging;
transform: scale(0.985);
box-shadow: none;
} }
.treeItemDropBefore { .treeItemDropBefore {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); @include treeNav.item-drop-before;
} }
.treeItemDropAfter { .treeItemDropAfter {
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--color-accent-strong) 78%, transparent); @include treeNav.item-drop-after;
} }
.treeItemDropInside { .treeItemDropInside {
border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent); @include treeNav.item-drop-inside;
background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface));
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.folderChevron { .folderChevron {
color: var(--color-text-muted); @include treeNav.folder-chevron;
transition: transform 160ms var(--easing-standard);
} }
.folderChevronOpen { .folderChevronOpen {
transform: rotate(90deg); @include treeNav.folder-chevron-open;
} }
.treeItemActive { .treeItemActive {
border-color: var(--color-border); @include treeNav.item-active;
background: var(--color-surface);
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
} }
.navItemActive { .navItemActive {
@@ -262,18 +203,15 @@
} }
.icon { .icon {
color: inherit; @include treeNav.icon;
opacity: 0.85;
} }
.label { .label {
@include text-label; @include treeNav.label;
min-width: 0;
} }
.itemMeta { .itemMeta {
@include text-caption; @include treeNav.item-meta;
color: var(--color-text-muted);
} }
.sidebarCollapsed { .sidebarCollapsed {
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import {
Home, Home,
Keyboard, Keyboard,
LayoutGrid, LayoutGrid,
ListCollapse,
LogOut, LogOut,
Repeat, Repeat,
Search, Search,
@@ -128,6 +129,7 @@ export type WorkspaceStaticItem = SidebarItem & {
export type WorkspaceFolderNode = { export type WorkspaceFolderNode = {
id: string; id: string;
path?: string;
label: string; label: string;
kind: "folder"; kind: "folder";
icon: ShellIcon; icon: ShellIcon;
@@ -476,6 +478,7 @@ export const workspaceTree: readonly WorkspaceTreeNode[] = [
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [ export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search }, { id: "search-workspace", label: "Search workspace", icon: Search },
{ id: "toggle-workspace-folders", label: "Collapse all folders", icon: ListCollapse },
] as const; ] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [ export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
@@ -595,6 +598,12 @@ const getProjectCreateActions = (): readonly ProjectContextMenuAction[] =>
{ id: "new-folder", label: "New folder" }, { id: "new-folder", label: "New folder" },
] as const; ] 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 => ({ export const createProjectSurfaceTarget = (label = "Projects"): ProjectMenuTarget => ({
id: "project-surface", id: "project-surface",
label, label,
@@ -641,6 +650,10 @@ export const getProjectContextMenuSections = (target: ProjectMenuTarget): readon
id: "create", id: "create",
items: createActions, items: createActions,
}, },
{
id: "organize",
items: getProjectFolderDangerActions(),
},
] as const; ] as const;
case "project": case "project":
return [ return [
@@ -0,0 +1,188 @@
@use "../../../styles/tools/mixins" as *;
@mixin section-label {
@include text-caption;
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@mixin tree-list {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
@mixin empty-slot {
min-height: calc(var(--control-size-lg) - var(--space-2));
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border-radius: var(--radius-lg);
border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent);
opacity: 0.35;
}
@mixin input-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
}
@mixin input {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
font: inherit;
outline: none;
&::placeholder {
color: var(--color-text-muted);
}
}
@mixin item {
width: 100%;
min-width: 0;
display: grid;
position: relative;
isolation: isolate;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-radius: 0;
background: transparent;
color: var(--color-text-muted);
text-align: left;
transition:
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
&::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
transition:
background 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
@mixin item-hover {
color: var(--color-text);
&::after {
background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin item-folder {
color: var(--color-text);
}
@mixin item-dragging {
opacity: 0.45;
transform: scale(0.985);
box-shadow: none;
}
@mixin item-drop-before {
&::before {
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
top: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
}
@mixin item-drop-after {
&::before {
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
bottom: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
}
@mixin item-drop-inside {
color: var(--color-text);
&::after {
border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent);
background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface));
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin folder-chevron {
color: var(--color-text-muted);
transition: transform 160ms var(--easing-standard);
}
@mixin folder-chevron-open {
transform: rotate(90deg);
}
@mixin item-active {
color: var(--color-text);
&::after {
border-color: var(--color-border);
background: var(--color-surface);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin icon {
color: inherit;
opacity: 0.85;
}
@mixin label {
@include text-label;
min-width: 0;
}
@mixin item-meta {
@include text-caption;
color: var(--color-text-muted);
}
@@ -0,0 +1,278 @@
export type NavTreeDropIntent = "before" | "after" | "inside";
export type NavTreeDropTarget = {
parentId: string | null;
index: number;
intent: NavTreeDropIntent;
targetNodeId?: string;
};
export type NavTreeDragState = {
draggedNodeId: string;
dropTarget: NavTreeDropTarget | null;
};
export type NavTreeLocation<TNode> = {
parentId: string | null;
index: number;
node: TNode;
};
export type NavTreeAdapter<TNode> = {
getNodeId: (node: TNode) => string;
isBranchNode: (node: TNode) => boolean;
getChildren: (node: TNode) => readonly TNode[];
withChildren: (node: TNode, children: readonly TNode[]) => TNode;
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const isUuidString = (value: string | null | undefined): boolean => {
if (typeof value !== "string") {
return false;
}
return UUID_PATTERN.test(value.trim());
};
export const collectBranchNodeIds = <TNode>(
nodes: readonly TNode[],
adapter: NavTreeAdapter<TNode>,
): string[] => {
const ids: string[] = [];
for (const node of nodes) {
if (!adapter.isBranchNode(node)) {
continue;
}
ids.push(adapter.getNodeId(node));
ids.push(...collectBranchNodeIds(adapter.getChildren(node), adapter));
}
return ids;
};
export const findTreeNodeLocation = <TNode>(
nodes: readonly TNode[],
nodeId: string,
adapter: NavTreeAdapter<TNode>,
parentId: string | null = null,
): NavTreeLocation<TNode> | null => {
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (adapter.getNodeId(node) === nodeId) {
return { parentId, index, node };
}
if (!adapter.isBranchNode(node)) {
continue;
}
const nestedLocation = findTreeNodeLocation(adapter.getChildren(node), nodeId, adapter, adapter.getNodeId(node));
if (nestedLocation) {
return nestedLocation;
}
}
return null;
};
export const findTreeNodeDepth = <TNode>(
nodes: readonly TNode[],
nodeId: string,
adapter: NavTreeAdapter<TNode>,
depth = 0,
): number | null => {
for (const node of nodes) {
if (adapter.getNodeId(node) === nodeId) {
return depth;
}
if (!adapter.isBranchNode(node)) {
continue;
}
const nestedDepth = findTreeNodeDepth(adapter.getChildren(node), nodeId, adapter, depth + 1);
if (nestedDepth !== null) {
return nestedDepth;
}
}
return null;
};
export const treeContainsNode = <TNode>(
nodes: readonly TNode[],
nodeId: string,
adapter: NavTreeAdapter<TNode>,
): boolean => {
for (const node of nodes) {
if (adapter.getNodeId(node) === nodeId) {
return true;
}
if (adapter.isBranchNode(node) && treeContainsNode(adapter.getChildren(node), nodeId, adapter)) {
return true;
}
}
return false;
};
export const removeTreeNode = <TNode>(
nodes: readonly TNode[],
nodeId: string,
adapter: NavTreeAdapter<TNode>,
): { nodes: TNode[]; removed: TNode | null } => {
const nextNodes: TNode[] = [];
let removed: TNode | null = null;
for (const node of nodes) {
if (adapter.getNodeId(node) === nodeId) {
removed = node;
continue;
}
if (adapter.isBranchNode(node)) {
const result = removeTreeNode(adapter.getChildren(node), nodeId, adapter);
if (result.removed) {
removed = result.removed;
nextNodes.push(adapter.withChildren(node, result.nodes));
continue;
}
}
nextNodes.push(node);
}
return { nodes: nextNodes, removed };
};
export const insertTreeNode = <TNode>(
nodes: readonly TNode[],
parentId: string | null,
index: number,
nodeToInsert: TNode,
adapter: NavTreeAdapter<TNode>,
): TNode[] => {
if (parentId === null) {
const nextNodes = [...nodes];
nextNodes.splice(Math.max(0, Math.min(index, nextNodes.length)), 0, nodeToInsert);
return nextNodes;
}
return nodes.map((node) => {
if (!adapter.isBranchNode(node)) {
return node;
}
if (adapter.getNodeId(node) === parentId) {
const nextChildren = [...adapter.getChildren(node)];
nextChildren.splice(Math.max(0, Math.min(index, nextChildren.length)), 0, nodeToInsert);
return adapter.withChildren(node, nextChildren);
}
return adapter.withChildren(node, insertTreeNode(adapter.getChildren(node), parentId, index, nodeToInsert, adapter));
});
};
export const moveTreeNode = <TNode>(
nodes: readonly TNode[],
draggedNodeId: string,
dropTarget: NavTreeDropTarget,
adapter: NavTreeAdapter<TNode>,
): TNode[] => {
const location = findTreeNodeLocation(nodes, draggedNodeId, adapter);
if (!location) {
return [...nodes];
}
if (
adapter.isBranchNode(location.node) &&
dropTarget.parentId !== null &&
(treeContainsNode(adapter.getChildren(location.node), dropTarget.parentId, adapter) ||
dropTarget.parentId === adapter.getNodeId(location.node))
) {
return [...nodes];
}
let normalizedIndex = dropTarget.index;
if (dropTarget.parentId === location.parentId && dropTarget.index > location.index) {
normalizedIndex -= 1;
}
if (dropTarget.parentId === location.parentId && normalizedIndex === location.index) {
return [...nodes];
}
const removalResult = removeTreeNode(nodes, draggedNodeId, adapter);
if (!removalResult.removed) {
return [...nodes];
}
return insertTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed, adapter);
};
export const getPointerRelativeY = (event: PointerEvent): number | null => {
const currentTarget = event.currentTarget;
if (!(currentTarget instanceof HTMLElement)) {
return null;
}
const bounds = currentTarget.getBoundingClientRect();
return bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height;
};
export const resolveTreeDropTarget = <TNode>(params: {
parentId: string | null;
index: number;
node: TNode;
relativeY: number;
adapter: NavTreeAdapter<TNode>;
beforeThreshold?: number;
beforeThresholdFirstSibling?: number;
afterThreshold?: number;
}): NavTreeDropTarget => {
const {
parentId,
index,
node,
relativeY,
adapter,
beforeThreshold = 0.28,
beforeThresholdFirstSibling = 0.42,
afterThreshold = 0.72,
} = params;
const targetNodeId = adapter.getNodeId(node);
if (adapter.isBranchNode(node)) {
const nextBeforeThreshold = index === 0 ? beforeThresholdFirstSibling : beforeThreshold;
if (relativeY < nextBeforeThreshold) {
return { parentId, index, intent: "before", targetNodeId };
}
if (relativeY > afterThreshold) {
return { parentId, index: index + 1, intent: "after", targetNodeId };
}
return {
parentId: targetNodeId,
index: adapter.getChildren(node).length,
intent: "inside",
targetNodeId,
};
}
return {
parentId,
index: relativeY < 0.5 ? index : index + 1,
intent: relativeY < 0.5 ? "before" : "after",
targetNodeId,
};
};
@@ -21,6 +21,29 @@ type BootstrapSubmissionState = {
error: string; error: string;
}; };
type InstanceForm = {
protocol: "http" | "https";
access: "local" | "remote";
host: string;
};
type ModeForm = {
mode: "personal" | "organizational";
name: string;
};
type AdminForm = {
displayName: string;
email: string;
password: string;
};
type StructureForm = {
departmentName: string;
teamName: string;
projectName: string;
};
const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
{ {
id: "instance", id: "instance",
@@ -44,37 +67,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
}, },
]; ];
const defaultInstanceForm = { const defaultInstanceForm: InstanceForm = {
protocol: "http", protocol: "http",
access: "local", access: "local",
host: "localhost", host: "localhost",
} as const; };
const defaultModeForm = { const defaultModeForm: ModeForm = {
mode: "personal", mode: "personal",
name: "", name: "",
} as const; };
const defaultAdminForm = { const defaultAdminForm: AdminForm = {
displayName: "Admin", displayName: "Admin",
email: "admin@example.com", email: "admin@example.com",
password: "", password: "",
} as const; };
const personalStructureDefaults = { const personalStructureDefaults = {
departmentName: "Default", departmentName: "Default",
teamName: "Personal", teamName: "Personal",
} as const; };
const organizationalStructureDefaults = { const organizationalStructureDefaults = {
departmentName: "Department", departmentName: "Department",
teamName: "Team", teamName: "Team",
} as const; };
const defaultStructureForm = { const defaultStructureForm: StructureForm = {
...personalStructureDefaults, ...personalStructureDefaults,
projectName: "Project", projectName: "Project",
} as const; };
const initialSubmissionState = (): BootstrapSubmissionState => ({ const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle", status: "idle",
@@ -148,10 +171,10 @@ type WorkspaceHomeProps = {
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); const [instanceForm, setInstanceForm] = createStore<InstanceForm>({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({ const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(), instance: initialSubmissionState(),
mode: initialSubmissionState(), mode: initialSubmissionState(),
@@ -363,7 +386,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1> <h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}> <Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}> <div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}> <button
type="button"
class={styles.primaryButton}
onClick={(): void => {
setIsWizardOpen(true);
}}
>
Open bootstrap wizard Open bootstrap wizard
</button> </button>
</div> </div>
@@ -384,7 +413,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</h2> </h2>
</div> </div>
<Show when={canDismissWizard()}> <Show when={canDismissWizard()}>
<button type="button" class={styles.wizardCloseButton} onClick={(): void => setIsWizardOpen(false)}> <button
type="button"
class={styles.wizardCloseButton}
onClick={(): void => {
setIsWizardOpen(false);
}}
>
Close Close
</button> </button>
</Show> </Show>
@@ -433,14 +468,24 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<> <>
<label class={styles.field}> <label class={styles.field}>
<span class={styles.fieldLabel}>Protocol</span> <span class={styles.fieldLabel}>Protocol</span>
<select value={instanceForm.protocol} onInput={(event): void => setInstanceForm("protocol", event.currentTarget.value)}> <select
value={instanceForm.protocol}
onInput={(event): void =>
setInstanceForm("protocol", event.currentTarget.value as InstanceForm["protocol"])
}
>
<option value="http">http</option> <option value="http">http</option>
<option value="https">https</option> <option value="https">https</option>
</select> </select>
</label> </label>
<label class={styles.field}> <label class={styles.field}>
<span class={styles.fieldLabel}>Access</span> <span class={styles.fieldLabel}>Access</span>
<select value={instanceForm.access} onInput={(event): void => setInstanceForm("access", event.currentTarget.value)}> <select
value={instanceForm.access}
onInput={(event): void =>
setInstanceForm("access", event.currentTarget.value as InstanceForm["access"])
}
>
<option value="local">local</option> <option value="local">local</option>
<option value="remote">remote</option> <option value="remote">remote</option>
</select> </select>
@@ -461,7 +506,10 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<> <>
<label class={styles.field}> <label class={styles.field}>
<span class={styles.fieldLabel}>Mode</span> <span class={styles.fieldLabel}>Mode</span>
<select value={modeForm.mode} onInput={(event): void => setModeForm("mode", event.currentTarget.value)}> <select
value={modeForm.mode}
onInput={(event): void => setModeForm("mode", event.currentTarget.value as ModeForm["mode"])}
>
<option value="personal">personal</option> <option value="personal">personal</option>
<option value="organizational">organizational</option> <option value="organizational">organizational</option>
</select> </select>
@@ -553,7 +601,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
type="button" type="button"
class={styles.secondaryButton} class={styles.secondaryButton}
disabled={isFirstStep()} disabled={isFirstStep()}
onClick={(): void => setCurrentStepIndex((index) => Math.max(index - 1, 0))} onClick={(): void => {
setCurrentStepIndex((index) => Math.max(index - 1, 0));
}}
> >
Back Back
</button> </button>
+2
View File
@@ -10,6 +10,7 @@ export { default as Folder } from "lucide-solid/icons/folder";
export { default as Home } from "lucide-solid/icons/house"; export { default as Home } from "lucide-solid/icons/house";
export { default as Keyboard } from "lucide-solid/icons/keyboard"; export { default as Keyboard } from "lucide-solid/icons/keyboard";
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid"; export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
export { default as ListCollapse } from "lucide-solid/icons/list-collapse";
export { default as LogOut } from "lucide-solid/icons/log-out"; export { default as LogOut } from "lucide-solid/icons/log-out";
export { default as Moon } from "lucide-solid/icons/moon"; export { default as Moon } from "lucide-solid/icons/moon";
export { default as Plus } from "lucide-solid/icons/plus"; export { default as Plus } from "lucide-solid/icons/plus";
@@ -18,5 +19,6 @@ export { default as Search } from "lucide-solid/icons/search";
export { default as Settings } from "lucide-solid/icons/settings"; export { default as Settings } from "lucide-solid/icons/settings";
export { default as Shield } from "lucide-solid/icons/shield"; export { default as Shield } from "lucide-solid/icons/shield";
export { default as Sun } from "lucide-solid/icons/sun"; export { default as Sun } from "lucide-solid/icons/sun";
export { default as UnfoldVertical } from "lucide-solid/icons/unfold-vertical";
export { default as User } from "lucide-solid/icons/user"; export { default as User } from "lucide-solid/icons/user";
export { default as X } from "lucide-solid/icons/x"; export { default as X } from "lucide-solid/icons/x";
+1
View File
@@ -1,6 +1,7 @@
set shell := ["bash", "-cu"] set shell := ["bash", "-cu"]
mod local "Commands/Local" mod local "Commands/Local"
mod test "Commands/Test"
[default] [default]
help: help: