From 2d2b596f2ca29d8b194494ba5749348f5ed25aaf Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 21:08:37 +0200 Subject: [PATCH 01/32] Add db support for workspaces --- backend/internal/db/migrations.go | 47 +++++++++++++++++++++++++++ backend/internal/db/settings.go | 28 ++++++++-------- backend/internal/db/user.go | 31 ++++++++++++++++++ backend/internal/db/workspace.go | 48 ++++++++++++++++++++++++++++ backend/internal/filesystem/paths.go | 29 +++++++++++++++++ backend/internal/models/settings.go | 34 +++++++------------- backend/internal/models/user.go | 17 ++++++++++ backend/internal/models/workspace.go | 16 ++++++++++ 8 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 backend/internal/db/user.go create mode 100644 backend/internal/db/workspace.go create mode 100644 backend/internal/filesystem/paths.go create mode 100644 backend/internal/models/user.go create mode 100644 backend/internal/models/workspace.go diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index 1973da6..c70a2f5 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -18,6 +18,53 @@ var migrations = []Migration{ settings JSON NOT NULL )`, }, + { + Version: 2, + SQL: ` + -- Create users table + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Create workspaces table + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + + -- Create workspace_settings table + CREATE TABLE IF NOT EXISTS workspace_settings ( + workspace_id INTEGER PRIMARY KEY, + settings JSON NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces (id) + ); + + -- Migrate existing settings to a default user and workspace + INSERT INTO users (username, email, password_hash) + VALUES ('default_user', 'default@example.com', 'placeholder_hash'); + + INSERT INTO workspaces (user_id, name, root_path) + SELECT 1, 'Default Workspace' + WHERE NOT EXISTS (SELECT 1 FROM workspaces); + + INSERT INTO workspace_settings (workspace_id, settings) + SELECT w.id, s.settings + FROM workspaces w + CROSS JOIN settings s + WHERE w.name = 'Default Workspace' + AND NOT EXISTS (SELECT 1 FROM workspace_settings); + + -- Drop the old settings table + DROP TABLE IF EXISTS settings; + `, + }, } func (db *DB) Migrate() error { diff --git a/backend/internal/db/settings.go b/backend/internal/db/settings.go index f0da94a..00820b6 100644 --- a/backend/internal/db/settings.go +++ b/backend/internal/db/settings.go @@ -7,39 +7,37 @@ import ( "novamd/internal/models" ) -func (db *DB) GetSettings(userID int) (models.Settings, error) { - var settings models.Settings +func (db *DB) GetWorkspaceSettings(workspaceID int) (*models.WorkspaceSettings, error) { + var settings models.WorkspaceSettings var settingsJSON []byte - err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON) + err := db.QueryRow("SELECT workspace_id, settings FROM workspace_settings WHERE workspace_id = ?", workspaceID). + Scan(&settings.WorkspaceID, &settingsJSON) if err != nil { if err == sql.ErrNoRows { // If no settings found, return default settings - settings.UserID = userID + settings.WorkspaceID = workspaceID settings.Settings = models.UserSettings{} // This will be filled with defaults later - return settings, nil + return &settings, nil } - return settings, err + return nil, err } err = json.Unmarshal(settingsJSON, &settings.Settings) if err != nil { - return settings, err + return nil, err } - return settings, nil + return &settings, nil } -func (db *DB) SaveSettings(settings models.Settings) error { - if err := settings.Validate(); err != nil { - return err - } - +func (db *DB) SaveWorkspaceSettings(settings *models.WorkspaceSettings) error { settingsJSON, err := json.Marshal(settings.Settings) if err != nil { return err } - _, err = db.Exec("INSERT OR REPLACE INTO settings (user_id, settings) VALUES (?, json(?))", settings.UserID, string(settingsJSON)) + _, err = db.Exec("INSERT OR REPLACE INTO workspace_settings (workspace_id, settings) VALUES (?, ?)", + settings.WorkspaceID, settingsJSON) return err -} +} \ No newline at end of file diff --git a/backend/internal/db/user.go b/backend/internal/db/user.go new file mode 100644 index 0000000..610d07d --- /dev/null +++ b/backend/internal/db/user.go @@ -0,0 +1,31 @@ +package db + +import ( + "novamd/internal/models" +) + +func (db *DB) CreateUser(user *models.User) error { + _, err := db.Exec("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)", + user.Username, user.Email, user.PasswordHash) + return err +} + +func (db *DB) GetUserByID(id int) (*models.User, error) { + user := &models.User{} + err := db.QueryRow("SELECT id, username, email, created_at FROM users WHERE id = ?", id). + Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt) + if err != nil { + return nil, err + } + return user, nil +} + +func (db *DB) GetUserByUsername(username string) (*models.User, error) { + user := &models.User{} + err := db.QueryRow("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?", username). + Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) + if err != nil { + return nil, err + } + return user, nil +} \ No newline at end of file diff --git a/backend/internal/db/workspace.go b/backend/internal/db/workspace.go new file mode 100644 index 0000000..0e8f3d4 --- /dev/null +++ b/backend/internal/db/workspace.go @@ -0,0 +1,48 @@ +package db + +import ( + "novamd/internal/models" +) + +func (db *DB) CreateWorkspace(workspace *models.Workspace) error { + result, err := db.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?, ?)", + workspace.UserID, workspace.Name) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + workspace.ID = int(id) + return nil +} + +func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { + workspace := &models.Workspace{} + err := db.QueryRow("SELECT id, user_id, name, root_path, created_at FROM workspaces WHERE id = ?", id). + Scan(&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt) + if err != nil { + return nil, err + } + return workspace, nil +} + +func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { + rows, err := db.Query("SELECT id, user_id, name, root_path, created_at FROM workspaces WHERE user_id = ?", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var workspaces []*models.Workspace + for rows.Next() { + workspace := &models.Workspace{} + err := rows.Scan(&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt) + if err != nil { + return nil, err + } + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} \ No newline at end of file diff --git a/backend/internal/filesystem/paths.go b/backend/internal/filesystem/paths.go new file mode 100644 index 0000000..fa0762e --- /dev/null +++ b/backend/internal/filesystem/paths.go @@ -0,0 +1,29 @@ +package filesystem + +import ( + "fmt" + "os" + "path/filepath" + + "novamd/internal/models" +) + +// GetWorkspacePath returns the file system path for a given workspace +func GetWorkspacePath(workspace *models.Workspace) string { + baseDir := os.Getenv("NOVAMD_WORKDIR") + if baseDir == "" { + baseDir = "./data" // Default if not set + } + return filepath.Join(baseDir, fmt.Sprintf("%d", workspace.UserID), workspace.Name) +} + +// GetFilePath returns the file system path for a given file within a workspace +func GetFilePath(workspace *models.Workspace, relativeFilePath string) string { + return filepath.Join(GetWorkspacePath(workspace), relativeFilePath) +} + +// EnsureWorkspaceDirectory creates the workspace directory if it doesn't exist +func EnsureWorkspaceDirectory(workspace *models.Workspace) error { + dir := GetWorkspacePath(workspace) + return os.MkdirAll(dir, 0755) +} \ No newline at end of file diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 602bd45..52a15cb 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -17,42 +17,30 @@ type UserSettings struct { GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` } -type Settings struct { - UserID int `json:"userId" validate:"required,min=1"` - Settings UserSettings `json:"settings" validate:"required"` -} - -var defaultUserSettings = UserSettings{ - Theme: "light", - AutoSave: false, - GitEnabled: false, - GitCommitMsgTemplate: "Update ${filename}", +type WorkspaceSettings struct { + WorkspaceID int `json:"workspaceId" validate:"required,min=1"` + Settings UserSettings `json:"settings" validate:"required"` } var validate = validator.New() -func (s *Settings) Validate() error { +func (s *UserSettings) Validate() error { return validate.Struct(s) } -func (s *Settings) SetDefaults() { - if s.Settings.Theme == "" { - s.Settings.Theme = defaultUserSettings.Theme - } - if s.Settings.GitCommitMsgTemplate == "" { - s.Settings.GitCommitMsgTemplate = defaultUserSettings.GitCommitMsgTemplate - } +func (ws *WorkspaceSettings) Validate() error { + return validate.Struct(ws) } -func (s *Settings) UnmarshalJSON(data []byte) error { - type Alias Settings +func (ws *WorkspaceSettings) UnmarshalJSON(data []byte) error { + type Alias WorkspaceSettings aux := &struct { *Alias }{ - Alias: (*Alias)(s), + Alias: (*Alias)(ws), } if err := json.Unmarshal(data, &aux); err != nil { return err } - return s.Validate() -} + return ws.Validate() +} \ No newline at end of file diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..9283327 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" +) + +type User struct { + ID int `json:"id" validate:"required,min=1"` + Username string `json:"username" validate:"required"` + Email string `json:"email" validate:"required,email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` +} + +func (u *User) Validate() error { + return validate.Struct(u) +} \ No newline at end of file diff --git a/backend/internal/models/workspace.go b/backend/internal/models/workspace.go new file mode 100644 index 0000000..ced4816 --- /dev/null +++ b/backend/internal/models/workspace.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +type Workspace struct { + ID int `json:"id" validate:"required,min=1"` + UserID int `json:"userId" validate:"required,min=1"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"createdAt"` +} + +func (w *Workspace) Validate() error { + return validate.Struct(w) +} From 97ebf1c08ef62c4482d3890559893bfe9c6c083d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 21:26:31 +0200 Subject: [PATCH 02/32] Update fs for workspaces --- backend/internal/filesystem/filesystem.go | 173 ++++++---------------- backend/internal/filesystem/paths.go | 6 - 2 files changed, 46 insertions(+), 133 deletions(-) diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index b589ea8..e101366 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -3,20 +3,14 @@ package filesystem import ( "errors" "fmt" - "novamd/internal/gitutils" - "novamd/internal/models" "os" "path/filepath" - "sort" "strings" + + "novamd/internal/models" ) -type FileSystem struct { - RootDir string - GitRepo *gitutils.GitRepo - Settings *models.Settings -} - +// FileNode represents a file or directory in the file system type FileNode struct { ID string `json:"id"` Name string `json:"name"` @@ -24,136 +18,72 @@ type FileNode struct { Children []FileNode `json:"children,omitempty"` } -func New(rootDir string, settings *models.Settings) *FileSystem { - fs := &FileSystem{ - RootDir: rootDir, - Settings: settings, - } - - if settings.Settings.GitEnabled { - fs.GitRepo = gitutils.New( - settings.Settings.GitURL, - settings.Settings.GitUser, - settings.Settings.GitToken, - rootDir, - ) - } - - return fs -} - -func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error { - fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir) - return fs.InitializeGitRepo() -} - -func (fs *FileSystem) DisableGitRepo() { - fs.GitRepo = nil; -} - -func (fs *FileSystem) InitializeGitRepo() error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") - } - - return fs.GitRepo.EnsureRepo() -} - -func ValidatePath(rootDir, path string) (string, error) { - fullPath := filepath.Join(rootDir, path) +// ValidatePath ensures the given path is within the workspace +func ValidatePath(workspace *models.Workspace, path string) (string, error) { + workspacePath := GetWorkspacePath(workspace) + fullPath := filepath.Join(workspacePath, path) cleanPath := filepath.Clean(fullPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) { - return "", fmt.Errorf("invalid path: outside of root directory") - } - - relPath, err := filepath.Rel(rootDir, cleanPath) - if err != nil { - return "", err - } - - if strings.HasPrefix(relPath, "..") { - return "", fmt.Errorf("invalid path: outside of root directory") + if !strings.HasPrefix(cleanPath, workspacePath) { + return "", fmt.Errorf("invalid path: outside of workspace") } return cleanPath, nil } -func (fs *FileSystem) validatePath(path string) (string, error) { - return ValidatePath(fs.RootDir, path) +// ListFilesRecursively returns a list of all files in the workspace +func ListFilesRecursively(workspace *models.Workspace) ([]FileNode, error) { + workspacePath := GetWorkspacePath(workspace) + return walkDirectory(workspacePath, "") } -func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) { - return fs.walkDirectory(fs.RootDir, "") -} - -func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { +func walkDirectory(dir, prefix string) ([]FileNode, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } - var folders []FileNode - var files []FileNode - + var nodes []FileNode for _, entry := range entries { name := entry.Name() path := filepath.Join(prefix, name) fullPath := filepath.Join(dir, name) + node := FileNode{ + ID: path, + Name: name, + Path: path, + } + if entry.IsDir() { - children, err := fs.walkDirectory(fullPath, path) + children, err := walkDirectory(fullPath, path) if err != nil { return nil, err } - folders = append(folders, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - Children: children, - }) - } else { - files = append(files, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - }) + node.Children = children } + + nodes = append(nodes, node) } - // Sort folders and files alphabetically - sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) - sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name }) - - // Combine folders and files, with folders first - return append(folders, files...), nil + return nodes, nil } - -func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { +// FindFileByName searches for a file in the workspace by name +func FindFileByName(workspace *models.Workspace, filename string) ([]string, error) { var foundPaths []string - var searchPattern string + workspacePath := GetWorkspacePath(workspace) - // If no extension is provided, assume .md - if !strings.Contains(filenameOrPath, ".") { - searchPattern = filenameOrPath + ".md" - } else { - searchPattern = filenameOrPath - } - - err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { - relPath, err := filepath.Rel(fs.RootDir, path) + relPath, err := filepath.Rel(workspacePath, path) if err != nil { return err } - - // Check if the file matches the search pattern - if strings.HasSuffix(relPath, searchPattern) || - strings.EqualFold(info.Name(), searchPattern) { + if strings.EqualFold(info.Name(), filename) { foundPaths = append(foundPaths, relPath) } } @@ -171,16 +101,18 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { return foundPaths, nil } -func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { - fullPath, err := fs.validatePath(filePath) +// GetFileContent retrieves the content of a file in the workspace +func GetFileContent(workspace *models.Workspace, filePath string) ([]byte, error) { + fullPath, err := ValidatePath(workspace, filePath) if err != nil { return nil, err } return os.ReadFile(fullPath) } -func (fs *FileSystem) SaveFile(filePath string, content []byte) error { - fullPath, err := fs.validatePath(filePath) +// SaveFile saves content to a file in the workspace +func SaveFile(workspace *models.Workspace, filePath string, content []byte) error { + fullPath, err := ValidatePath(workspace, filePath) if err != nil { return err } @@ -193,30 +125,17 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error { return os.WriteFile(fullPath, content, 0644) } -func (fs *FileSystem) DeleteFile(filePath string) error { - fullPath, err := fs.validatePath(filePath) +// DeleteFile removes a file from the workspace +func DeleteFile(workspace *models.Workspace, filePath string) error { + fullPath, err := ValidatePath(workspace, filePath) if err != nil { return err } return os.Remove(fullPath) } -func (fs *FileSystem) StageCommitAndPush(message string) error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") - } - - if err := fs.GitRepo.Commit(message); err != nil { - return err - } - - return fs.GitRepo.Push() -} - -func (fs *FileSystem) Pull() error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") - } - - return fs.GitRepo.Pull() -} +// CreateWorkspaceDirectory creates the directory for a new workspace +func CreateWorkspaceDirectory(workspace *models.Workspace) error { + dir := GetWorkspacePath(workspace) + return os.MkdirAll(dir, 0755) +} \ No newline at end of file diff --git a/backend/internal/filesystem/paths.go b/backend/internal/filesystem/paths.go index fa0762e..91cb6fd 100644 --- a/backend/internal/filesystem/paths.go +++ b/backend/internal/filesystem/paths.go @@ -21,9 +21,3 @@ func GetWorkspacePath(workspace *models.Workspace) string { func GetFilePath(workspace *models.Workspace, relativeFilePath string) string { return filepath.Join(GetWorkspacePath(workspace), relativeFilePath) } - -// EnsureWorkspaceDirectory creates the workspace directory if it doesn't exist -func EnsureWorkspaceDirectory(workspace *models.Workspace) error { - dir := GetWorkspacePath(workspace) - return os.MkdirAll(dir, 0755) -} \ No newline at end of file From 18bc50f5b4844869d5372e5cd8aab1d75ec8c285 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 22:01:17 +0200 Subject: [PATCH 03/32] Update handlers for workspaces --- backend/internal/api/handlers.go | 121 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 56 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 717094e..75ff6da 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -4,7 +4,6 @@ import ( "encoding/json" "io" "net/http" - "path/filepath" "strconv" "strings" @@ -14,8 +13,14 @@ import ( ) func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - files, err := fs.ListFilesRecursively() + return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + + files, err := fs.ListFilesRecursively(workspaceID) if err != nil { http.Error(w, "Failed to list files", http.StatusInternalServerError) return @@ -30,13 +35,19 @@ func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + filenameOrPath := r.URL.Query().Get("filename") if filenameOrPath == "" { http.Error(w, "Filename or path is required", http.StatusBadRequest) return } - filePaths, err := fs.FindFileByName(filenameOrPath) + filePaths, err := fs.FindFileByName(workspaceID, filenameOrPath) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return @@ -51,39 +62,32 @@ func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - content, err := fs.GetFileContent(filePath) + content, err := fs.GetFileContent(workspaceID, filePath) if err != nil { http.Error(w, "Failed to read file", http.StatusNotFound) return } - // Determine content type based on file extension - contentType := "text/plain" - switch filepath.Ext(filePath) { - case ".png": - contentType = "image/png" - case ".jpg", ".jpeg": - contentType = "image/jpeg" - case ".webp": - contentType = "image/webp" - case ".gif": - contentType = "image/gif" - case ".svg": - contentType = "image/svg+xml" - case ".md": - contentType = "text/markdown" - } - - w.Header().Set("Content-Type", contentType) - if _, err := w.Write(content); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - } + w.Header().Set("Content-Type", "text/plain") + w.Write(content) } } func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") content, err := io.ReadAll(r.Body) if err != nil { @@ -91,7 +95,7 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.SaveFile(filePath, content) + err = fs.SaveFile(workspaceID, filePath, content) if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) return @@ -106,37 +110,38 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - err := fs.DeleteFile(filePath) + err = fs.DeleteFile(workspaceID, filePath) if err != nil { http.Error(w, "Failed to delete file", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("File deleted successfully")); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - } + w.Write([]byte("File deleted successfully")) } } func GetSettings(db *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userIDStr := r.URL.Query().Get("userId") - userID, err := strconv.Atoi(userIDStr) + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) if err != nil { - http.Error(w, "Invalid userId", http.StatusBadRequest) + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) return } - settings, err := db.GetSettings(userID) + settings, err := db.GetWorkspaceSettings(workspaceID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - settings.SetDefaults() - w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(settings); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) @@ -146,44 +151,36 @@ func GetSettings(db *db.DB) http.HandlerFunc { func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var settings models.Settings + var settings models.WorkspaceSettings if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - settings.SetDefaults() - if err := settings.Validate(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - err := db.SaveSettings(settings) + err := db.SaveWorkspaceSettings(settings) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if settings.Settings.GitEnabled { - err := fs.SetupGitRepo(settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) + err := fs.SetupGitRepo(settings.WorkspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) if err != nil { http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) + return } } else { - fs.DisableGitRepo() - } - - // Fetch the saved settings to return - savedSettings, err := db.GetSettings(settings.UserID) - if err != nil { - http.Error(w, "Settings saved but could not be retrieved", http.StatusInternalServerError) - return + fs.DisableGitRepo(settings.WorkspaceID) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(savedSettings); err != nil { + if err := json.NewEncoder(w).Encode(settings); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } @@ -191,11 +188,17 @@ func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + var requestBody struct { Message string `json:"message"` } - err := json.NewDecoder(r.Body).Decode(&requestBody) + err = json.NewDecoder(r.Body).Decode(&requestBody) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -206,7 +209,7 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.StageCommitAndPush(requestBody.Message) + err = fs.StageCommitAndPush(workspaceID, requestBody.Message) if err != nil { http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) return @@ -220,8 +223,14 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { } func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - err := fs.Pull() + return func(w http.ResponseWriter, r *http.Request) { + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + return + } + + err = fs.Pull(workspaceID) if err != nil { http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return @@ -232,4 +241,4 @@ func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } -} +} \ No newline at end of file From cbdf51db05617c323fcfd273c7f116c44560bb33 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 22:06:38 +0200 Subject: [PATCH 04/32] Fix filesystem --- backend/internal/filesystem/filesystem.go | 93 ++++++++++++++++------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index e101366..8fe6985 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -3,14 +3,17 @@ package filesystem import ( "errors" "fmt" + "novamd/internal/gitutils" "os" "path/filepath" "strings" - - "novamd/internal/models" ) -// FileNode represents a file or directory in the file system +type FileSystem struct { + RootDir string + GitRepos map[int]*gitutils.GitRepo +} + type FileNode struct { ID string `json:"id"` Name string `json:"name"` @@ -18,9 +21,19 @@ type FileNode struct { Children []FileNode `json:"children,omitempty"` } -// ValidatePath ensures the given path is within the workspace -func ValidatePath(workspace *models.Workspace, path string) (string, error) { - workspacePath := GetWorkspacePath(workspace) +func New(rootDir string) *FileSystem { + return &FileSystem{ + RootDir: rootDir, + GitRepos: make(map[int]*gitutils.GitRepo), + } +} + +func (fs *FileSystem) GetWorkspacePath(workspaceID int) string { + return filepath.Join(fs.RootDir, fmt.Sprintf("%d", workspaceID)) +} + +func (fs *FileSystem) ValidatePath(workspaceID int, path string) (string, error) { + workspacePath := fs.GetWorkspacePath(workspaceID) fullPath := filepath.Join(workspacePath, path) cleanPath := filepath.Clean(fullPath) @@ -31,13 +44,12 @@ func ValidatePath(workspace *models.Workspace, path string) (string, error) { return cleanPath, nil } -// ListFilesRecursively returns a list of all files in the workspace -func ListFilesRecursively(workspace *models.Workspace) ([]FileNode, error) { - workspacePath := GetWorkspacePath(workspace) - return walkDirectory(workspacePath, "") +func (fs *FileSystem) ListFilesRecursively(workspaceID int) ([]FileNode, error) { + workspacePath := fs.GetWorkspacePath(workspaceID) + return fs.walkDirectory(workspacePath, "") } -func walkDirectory(dir, prefix string) ([]FileNode, error) { +func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err @@ -56,7 +68,7 @@ func walkDirectory(dir, prefix string) ([]FileNode, error) { } if entry.IsDir() { - children, err := walkDirectory(fullPath, path) + children, err := fs.walkDirectory(fullPath, path) if err != nil { return nil, err } @@ -69,10 +81,9 @@ func walkDirectory(dir, prefix string) ([]FileNode, error) { return nodes, nil } -// FindFileByName searches for a file in the workspace by name -func FindFileByName(workspace *models.Workspace, filename string) ([]string, error) { +func (fs *FileSystem) FindFileByName(workspaceID int, filename string) ([]string, error) { var foundPaths []string - workspacePath := GetWorkspacePath(workspace) + workspacePath := fs.GetWorkspacePath(workspaceID) err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -101,18 +112,16 @@ func FindFileByName(workspace *models.Workspace, filename string) ([]string, err return foundPaths, nil } -// GetFileContent retrieves the content of a file in the workspace -func GetFileContent(workspace *models.Workspace, filePath string) ([]byte, error) { - fullPath, err := ValidatePath(workspace, filePath) +func (fs *FileSystem) GetFileContent(workspaceID int, filePath string) ([]byte, error) { + fullPath, err := fs.ValidatePath(workspaceID, filePath) if err != nil { return nil, err } return os.ReadFile(fullPath) } -// SaveFile saves content to a file in the workspace -func SaveFile(workspace *models.Workspace, filePath string, content []byte) error { - fullPath, err := ValidatePath(workspace, filePath) +func (fs *FileSystem) SaveFile(workspaceID int, filePath string, content []byte) error { + fullPath, err := fs.ValidatePath(workspaceID, filePath) if err != nil { return err } @@ -125,17 +134,47 @@ func SaveFile(workspace *models.Workspace, filePath string, content []byte) erro return os.WriteFile(fullPath, content, 0644) } -// DeleteFile removes a file from the workspace -func DeleteFile(workspace *models.Workspace, filePath string) error { - fullPath, err := ValidatePath(workspace, filePath) +func (fs *FileSystem) DeleteFile(workspaceID int, filePath string) error { + fullPath, err := fs.ValidatePath(workspaceID, filePath) if err != nil { return err } return os.Remove(fullPath) } -// CreateWorkspaceDirectory creates the directory for a new workspace -func CreateWorkspaceDirectory(workspace *models.Workspace) error { - dir := GetWorkspacePath(workspace) +func (fs *FileSystem) CreateWorkspaceDirectory(workspaceID int) error { + dir := fs.GetWorkspacePath(workspaceID) return os.MkdirAll(dir, 0755) +} + +func (fs *FileSystem) SetupGitRepo(workspaceID int, gitURL, gitUser, gitToken string) error { + workspacePath := fs.GetWorkspacePath(workspaceID) + fs.GitRepos[workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) + return fs.GitRepos[workspaceID].EnsureRepo() +} + +func (fs *FileSystem) DisableGitRepo(workspaceID int) { + delete(fs.GitRepos, workspaceID) +} + +func (fs *FileSystem) StageCommitAndPush(workspaceID int, message string) error { + repo, ok := fs.GitRepos[workspaceID] + if !ok { + return errors.New("git settings not configured for this workspace") + } + + if err := repo.Commit(message); err != nil { + return err + } + + return repo.Push() +} + +func (fs *FileSystem) Pull(workspaceID int) error { + repo, ok := fs.GitRepos[workspaceID] + if !ok { + return errors.New("git settings not configured for this workspace") + } + + return repo.Pull() } \ No newline at end of file From 495313815482990f15b3cd582d6dcbfb5e0d73ff Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 22:36:37 +0200 Subject: [PATCH 05/32] Fix filesystem paths --- backend/internal/filesystem/filesystem.go | 75 ++++++++++++++--------- backend/internal/filesystem/paths.go | 23 ------- 2 files changed, 46 insertions(+), 52 deletions(-) delete mode 100644 backend/internal/filesystem/paths.go diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index 8fe6985..3007f9d 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -10,8 +10,8 @@ import ( ) type FileSystem struct { - RootDir string - GitRepos map[int]*gitutils.GitRepo + RootDir string + GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo } type FileNode struct { @@ -24,16 +24,16 @@ type FileNode struct { func New(rootDir string) *FileSystem { return &FileSystem{ RootDir: rootDir, - GitRepos: make(map[int]*gitutils.GitRepo), + GitRepos: make(map[int]map[int]*gitutils.GitRepo), } } -func (fs *FileSystem) GetWorkspacePath(workspaceID int) string { - return filepath.Join(fs.RootDir, fmt.Sprintf("%d", workspaceID)) +func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { + return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID)) } -func (fs *FileSystem) ValidatePath(workspaceID int, path string) (string, error) { - workspacePath := fs.GetWorkspacePath(workspaceID) +func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) fullPath := filepath.Join(workspacePath, path) cleanPath := filepath.Clean(fullPath) @@ -44,8 +44,8 @@ func (fs *FileSystem) ValidatePath(workspaceID int, path string) (string, error) return cleanPath, nil } -func (fs *FileSystem) ListFilesRecursively(workspaceID int) ([]FileNode, error) { - workspacePath := fs.GetWorkspacePath(workspaceID) +func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) return fs.walkDirectory(workspacePath, "") } @@ -81,9 +81,9 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { return nodes, nil } -func (fs *FileSystem) FindFileByName(workspaceID int, filename string) ([]string, error) { +func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) { var foundPaths []string - workspacePath := fs.GetWorkspacePath(workspaceID) + workspacePath := fs.GetWorkspacePath(userID, workspaceID) err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -112,16 +112,16 @@ func (fs *FileSystem) FindFileByName(workspaceID int, filename string) ([]string return foundPaths, nil } -func (fs *FileSystem) GetFileContent(workspaceID int, filePath string) ([]byte, error) { - fullPath, err := fs.ValidatePath(workspaceID, filePath) +func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return nil, err } return os.ReadFile(fullPath) } -func (fs *FileSystem) SaveFile(workspaceID int, filePath string, content []byte) error { - fullPath, err := fs.ValidatePath(workspaceID, filePath) +func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } @@ -134,31 +134,39 @@ func (fs *FileSystem) SaveFile(workspaceID int, filePath string, content []byte) return os.WriteFile(fullPath, content, 0644) } -func (fs *FileSystem) DeleteFile(workspaceID int, filePath string) error { - fullPath, err := fs.ValidatePath(workspaceID, filePath) +func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } return os.Remove(fullPath) } -func (fs *FileSystem) CreateWorkspaceDirectory(workspaceID int) error { - dir := fs.GetWorkspacePath(workspaceID) +func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error { + dir := fs.GetWorkspacePath(userID, workspaceID) return os.MkdirAll(dir, 0755) } -func (fs *FileSystem) SetupGitRepo(workspaceID int, gitURL, gitUser, gitToken string) error { - workspacePath := fs.GetWorkspacePath(workspaceID) - fs.GitRepos[workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) - return fs.GitRepos[workspaceID].EnsureRepo() +func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + if _, ok := fs.GitRepos[userID]; !ok { + fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo) + } + fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) + return fs.GitRepos[userID][workspaceID].EnsureRepo() } -func (fs *FileSystem) DisableGitRepo(workspaceID int) { - delete(fs.GitRepos, workspaceID) +func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) { + if userRepos, ok := fs.GitRepos[userID]; ok { + delete(userRepos, workspaceID) + if len(userRepos) == 0 { + delete(fs.GitRepos, userID) + } + } } -func (fs *FileSystem) StageCommitAndPush(workspaceID int, message string) error { - repo, ok := fs.GitRepos[workspaceID] +func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error { + repo, ok := fs.getGitRepo(userID, workspaceID) if !ok { return errors.New("git settings not configured for this workspace") } @@ -170,11 +178,20 @@ func (fs *FileSystem) StageCommitAndPush(workspaceID int, message string) error return repo.Push() } -func (fs *FileSystem) Pull(workspaceID int) error { - repo, ok := fs.GitRepos[workspaceID] +func (fs *FileSystem) Pull(userID, workspaceID int) error { + repo, ok := fs.getGitRepo(userID, workspaceID) if !ok { return errors.New("git settings not configured for this workspace") } return repo.Pull() +} + +func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) { + userRepos, ok := fs.GitRepos[userID] + if !ok { + return nil, false + } + repo, ok := userRepos[workspaceID] + return repo, ok } \ No newline at end of file diff --git a/backend/internal/filesystem/paths.go b/backend/internal/filesystem/paths.go deleted file mode 100644 index 91cb6fd..0000000 --- a/backend/internal/filesystem/paths.go +++ /dev/null @@ -1,23 +0,0 @@ -package filesystem - -import ( - "fmt" - "os" - "path/filepath" - - "novamd/internal/models" -) - -// GetWorkspacePath returns the file system path for a given workspace -func GetWorkspacePath(workspace *models.Workspace) string { - baseDir := os.Getenv("NOVAMD_WORKDIR") - if baseDir == "" { - baseDir = "./data" // Default if not set - } - return filepath.Join(baseDir, fmt.Sprintf("%d", workspace.UserID), workspace.Name) -} - -// GetFilePath returns the file system path for a given file within a workspace -func GetFilePath(workspace *models.Workspace, relativeFilePath string) string { - return filepath.Join(GetWorkspacePath(workspace), relativeFilePath) -} From d440ac0fd7bc668b65ddc1ccb98bd0f6ecfecba8 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 14 Oct 2024 22:43:00 +0200 Subject: [PATCH 06/32] Update handlers to pass user id --- backend/internal/api/handlers.go | 129 +++++++++++++++---------------- 1 file changed, 64 insertions(+), 65 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 75ff6da..a68b4eb 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "io" "net/http" "strconv" @@ -14,62 +15,56 @@ import ( func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - files, err := fs.ListFilesRecursively(workspaceID) + files, err := fs.ListFilesRecursively(userID, workspaceID) if err != nil { http.Error(w, "Failed to list files", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(files); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, files) } } func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - filenameOrPath := r.URL.Query().Get("filename") - if filenameOrPath == "" { - http.Error(w, "Filename or path is required", http.StatusBadRequest) + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) return } - filePaths, err := fs.FindFileByName(workspaceID, filenameOrPath) + filePaths, err := fs.FindFileByName(userID, workspaceID, filename) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, map[string][]string{"paths": filePaths}) } } func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - content, err := fs.GetFileContent(workspaceID, filePath) + content, err := fs.GetFileContent(userID, workspaceID, filePath) if err != nil { http.Error(w, "Failed to read file", http.StatusNotFound) return @@ -82,9 +77,9 @@ func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -95,29 +90,26 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.SaveFile(workspaceID, filePath, content) + err = fs.SaveFile(userID, workspaceID, filePath, content) if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"message": "File saved successfully"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, map[string]string{"message": "File saved successfully"}) } } func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - err = fs.DeleteFile(workspaceID, filePath) + err = fs.DeleteFile(userID, workspaceID, filePath) if err != nil { http.Error(w, "Failed to delete file", http.StatusInternalServerError) return @@ -130,9 +122,9 @@ func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { func GetSettings(db *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + _, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -142,55 +134,50 @@ func GetSettings(db *db.DB) http.HandlerFunc { return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(settings); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, settings) } } func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var settings models.WorkspaceSettings if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := settings.Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + settings.WorkspaceID = workspaceID - err := db.SaveWorkspaceSettings(settings) - if err != nil { + if err := db.SaveWorkspaceSettings(&settings); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if settings.Settings.GitEnabled { - err := fs.SetupGitRepo(settings.WorkspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) + err := fs.SetupGitRepo(userID, workspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) if err != nil { http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) return } } else { - fs.DisableGitRepo(settings.WorkspaceID) + fs.DisableGitRepo(userID, workspaceID) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(settings); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, settings) } } func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -198,8 +185,7 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { Message string `json:"message"` } - err = json.NewDecoder(r.Body).Decode(&requestBody) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } @@ -209,36 +195,49 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.StageCommitAndPush(workspaceID, requestBody.Message) + err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message) if err != nil { http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"message": "Changes staged, committed, and pushed successfully"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) } } func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { - http.Error(w, "Invalid workspaceId", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - err = fs.Pull(workspaceID) + err = fs.Pull(userID, workspaceID) if err != nil { http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]string{"message": "Pulled changes from remote"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) } +} + +func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) { + userID, err := strconv.Atoi(r.URL.Query().Get("userId")) + if err != nil { + return 0, 0, errors.New("invalid userId") + } + + workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) + if err != nil { + return 0, 0, errors.New("invalid workspaceId") + } + + return userID, workspaceID, nil +} + +func respondJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) } \ No newline at end of file From 071619cfb34e5b65d44aebcbc5bc92f73f1eb141 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Oct 2024 21:05:28 +0200 Subject: [PATCH 07/32] Rework db schema --- .vscode/settings.json | 3 +- backend/internal/db/migrations.go | 35 +++++--------------- backend/internal/db/user.go | 54 ++++++++++++++++++++++++++----- backend/internal/models/user.go | 14 ++++---- 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e0a5ace..4d7334d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,7 +18,8 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" - } + }, + "editor.defaultFormatter": "golang.go" }, "gopls": { "usePlaceholders": true, diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index c70a2f5..48e4d02 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -13,21 +13,16 @@ type Migration struct { var migrations = []Migration{ { Version: 1, - SQL: `CREATE TABLE IF NOT EXISTS settings ( - user_id INTEGER PRIMARY KEY, - settings JSON NOT NULL - )`, - }, - { - Version: 2, SQL: ` -- Create users table CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, + display_name TEXT, password_hash TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_workspace_id INTEGER, + last_opened_file_path TEXT ); -- Create workspaces table @@ -39,30 +34,16 @@ var migrations = []Migration{ FOREIGN KEY (user_id) REFERENCES users (id) ); + -- Add foreign key constraint to users table + ALTER TABLE users ADD CONSTRAINT fk_last_workspace + FOREIGN KEY (last_workspace_id) REFERENCES workspaces (id); + -- Create workspace_settings table CREATE TABLE IF NOT EXISTS workspace_settings ( workspace_id INTEGER PRIMARY KEY, settings JSON NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ); - - -- Migrate existing settings to a default user and workspace - INSERT INTO users (username, email, password_hash) - VALUES ('default_user', 'default@example.com', 'placeholder_hash'); - - INSERT INTO workspaces (user_id, name, root_path) - SELECT 1, 'Default Workspace' - WHERE NOT EXISTS (SELECT 1 FROM workspaces); - - INSERT INTO workspace_settings (workspace_id, settings) - SELECT w.id, s.settings - FROM workspaces w - CROSS JOIN settings s - WHERE w.name = 'Default Workspace' - AND NOT EXISTS (SELECT 1 FROM workspace_settings); - - -- Drop the old settings table - DROP TABLE IF EXISTS settings; `, }, } diff --git a/backend/internal/db/user.go b/backend/internal/db/user.go index 610d07d..ae913cd 100644 --- a/backend/internal/db/user.go +++ b/backend/internal/db/user.go @@ -5,27 +5,65 @@ import ( ) func (db *DB) CreateUser(user *models.User) error { - _, err := db.Exec("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)", - user.Username, user.Email, user.PasswordHash) + _, err := db.Exec(` + INSERT INTO users (email, display_name, password_hash) + VALUES (?, ?, ?)`, + user.Email, user.DisplayName, user.PasswordHash) return err } func (db *DB) GetUserByID(id int) (*models.User, error) { user := &models.User{} - err := db.QueryRow("SELECT id, username, email, created_at FROM users WHERE id = ?", id). - Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt) + err := db.QueryRow(` + SELECT id, email, display_name, created_at, last_workspace_id, last_opened_file_path + FROM users WHERE id = ?`, id). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, + &user.LastWorkspaceID, &user.LastOpenedFilePath) if err != nil { return nil, err } return user, nil } -func (db *DB) GetUserByUsername(username string) (*models.User, error) { +func (db *DB) GetUserByEmail(email string) (*models.User, error) { user := &models.User{} - err := db.QueryRow("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?", username). - Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) + err := db.QueryRow(` + SELECT id, email, display_name, password_hash, created_at, last_workspace_id, last_opened_file_path + FROM users WHERE email = ?`, email). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, + &user.LastWorkspaceID, &user.LastOpenedFilePath) if err != nil { return nil, err } return user, nil -} \ No newline at end of file +} + +func (db *DB) UpdateUser(user *models.User) error { + _, err := db.Exec(` + UPDATE users + SET email = ?, display_name = ?, last_workspace_id = ?, last_opened_file_path = ? + WHERE id = ?`, + user.Email, user.DisplayName, user.LastWorkspaceID, user.LastOpenedFilePath, user.ID) + return err +} + +func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error { + _, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) + return err +} + +func (db *DB) UpdateLastOpenedFile(userID int, filePath string) error { + _, err := db.Exec("UPDATE users SET last_opened_file_path = ? WHERE id = ?", filePath, userID) + return err +} + +func (db *DB) DeleteUser(id int) error { + _, err := db.Exec("DELETE FROM users WHERE id = ?", id) + return err +} + +func (db *DB) GetLastWorkspaceID(userID int) (int, error) { + var workspaceID int + err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID) + return workspaceID, err +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 9283327..49aa94d 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -5,13 +5,15 @@ import ( ) type User struct { - ID int `json:"id" validate:"required,min=1"` - Username string `json:"username" validate:"required"` - Email string `json:"email" validate:"required,email"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"createdAt"` + ID int `json:"id" validate:"required,min=1"` + Email string `json:"email" validate:"required,email"` + DisplayName string `json:"displayName"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + LastWorkspaceID int `json:"lastWorkspaceId"` + LastOpenedFilePath string `json:"lastOpenedFilePath"` } func (u *User) Validate() error { return validate.Struct(u) -} \ No newline at end of file +} From 6cf141bfd9d00807932140db9c16ee3770370a43 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Oct 2024 22:17:34 +0200 Subject: [PATCH 08/32] Rework api --- .../api/{handlers.go => file_handlers.go} | 115 ++------ backend/internal/api/git_handlers.go | 58 +++++ backend/internal/api/handler_utils.go | 37 +++ backend/internal/api/routes.go | 59 +++-- backend/internal/api/user_handlers.go | 25 ++ backend/internal/api/workspace_handlers.go | 246 ++++++++++++++++++ backend/internal/db/workspace.go | 13 +- 7 files changed, 443 insertions(+), 110 deletions(-) rename backend/internal/api/{handlers.go => file_handlers.go} (53%) create mode 100644 backend/internal/api/git_handlers.go create mode 100644 backend/internal/api/handler_utils.go create mode 100644 backend/internal/api/user_handlers.go create mode 100644 backend/internal/api/workspace_handlers.go diff --git a/backend/internal/api/handlers.go b/backend/internal/api/file_handlers.go similarity index 53% rename from backend/internal/api/handlers.go rename to backend/internal/api/file_handlers.go index a68b4eb..1a6e943 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/file_handlers.go @@ -2,15 +2,13 @@ package api import ( "encoding/json" - "errors" "io" "net/http" - "strconv" - "strings" "novamd/internal/db" "novamd/internal/filesystem" - "novamd/internal/models" + + "github.com/go-chi/chi/v5" ) func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { @@ -63,7 +61,7 @@ func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { return } - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") + filePath := chi.URLParam(r, "*") content, err := fs.GetFileContent(userID, workspaceID, filePath) if err != nil { http.Error(w, "Failed to read file", http.StatusNotFound) @@ -83,7 +81,7 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return } - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") + filePath := chi.URLParam(r, "*") content, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) @@ -108,7 +106,7 @@ func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { return } - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") + filePath := chi.URLParam(r, "*") err = fs.DeleteFile(userID, workspaceID, filePath) if err != nil { http.Error(w, "Failed to delete file", http.StatusInternalServerError) @@ -120,60 +118,25 @@ func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { } } -func GetSettings(db *db.DB) http.HandlerFunc { +func GetLastOpenedFile(db *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _, workspaceID, err := getUserAndWorkspaceIDs(r) + userID, _, err := getUserAndWorkspaceIDs(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - settings, err := db.GetWorkspaceSettings(workspaceID) + user, err := db.GetUserByID(userID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "Failed to get user", http.StatusInternalServerError) return } - respondJSON(w, settings) + respondJSON(w, map[string]string{"lastOpenedFile": user.LastOpenedFilePath}) } } -func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - var settings models.WorkspaceSettings - if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - settings.WorkspaceID = workspaceID - - if err := db.SaveWorkspaceSettings(&settings); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if settings.Settings.GitEnabled { - err := fs.SetupGitRepo(userID, workspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) - if err != nil { - http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) - return - } - } else { - fs.DisableGitRepo(userID, workspaceID) - } - - respondJSON(w, settings) - } -} - -func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { +func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { @@ -182,7 +145,7 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { } var requestBody struct { - Message string `json:"message"` + FilePath string `json:"filePath"` } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { @@ -190,54 +153,18 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return } - if requestBody.Message == "" { - http.Error(w, "Commit message is required", http.StatusBadRequest) - return - } - - err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message) + // Validate that the file path is valid within the workspace + _, err = fs.ValidatePath(userID, workspaceID, requestBody.FilePath) if err != nil { - http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "Invalid file path", http.StatusBadRequest) return } - respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) + if err := db.UpdateLastOpenedFile(userID, requestBody.FilePath); err != nil { + http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Last opened file updated successfully"}) } } - -func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - err = fs.Pull(userID, workspaceID) - if err != nil { - http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) - return - } - - respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) - } -} - -func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) { - userID, err := strconv.Atoi(r.URL.Query().Get("userId")) - if err != nil { - return 0, 0, errors.New("invalid userId") - } - - workspaceID, err := strconv.Atoi(r.URL.Query().Get("workspaceId")) - if err != nil { - return 0, 0, errors.New("invalid workspaceId") - } - - return userID, workspaceID, nil -} - -func respondJSON(w http.ResponseWriter, data interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) -} \ No newline at end of file diff --git a/backend/internal/api/git_handlers.go b/backend/internal/api/git_handlers.go new file mode 100644 index 0000000..7eaa146 --- /dev/null +++ b/backend/internal/api/git_handlers.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "net/http" + + "novamd/internal/filesystem" +) + +func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + Message string `json:"message"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if requestBody.Message == "" { + http.Error(w, "Commit message is required", http.StatusBadRequest) + return + } + + err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message) + if err != nil { + http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) + } +} + +func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = fs.Pull(userID, workspaceID) + if err != nil { + http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) + } +} diff --git a/backend/internal/api/handler_utils.go b/backend/internal/api/handler_utils.go new file mode 100644 index 0000000..75c50cd --- /dev/null +++ b/backend/internal/api/handler_utils.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" +) + +func getUserID(r *http.Request) (int, error) { + userIDStr := chi.URLParam(r, "userId") + return strconv.Atoi(userIDStr) +} + +func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) { + userID, err := getUserID(r) + if err != nil { + return 0, 0, errors.New("invalid userId") + } + + workspaceIDStr := chi.URLParam(r, "workspaceId") + workspaceID, err := strconv.Atoi(workspaceIDStr) + if err != nil { + return userID, 0, errors.New("invalid workspaceId") + } + + return userID, workspaceID, nil +} + +func respondJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 761bab7..058cff6 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -8,21 +8,50 @@ import ( ) func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { - r.Route("/", func(r chi.Router) { - r.Route("/settings", func(r chi.Router) { - r.Get("/", GetSettings(db)) - r.Post("/", UpdateSettings(db, fs)) - }) - r.Route("/files", func(r chi.Router) { - r.Get("/", ListFiles(fs)) - r.Get("/*", GetFileContent(fs)) - r.Post("/*", SaveFile(fs)) - r.Delete("/*", DeleteFile(fs)) - r.Get("/lookup", LookupFileByName(fs)) - }) - r.Route("/git", func(r chi.Router) { - r.Post("/commit", StageCommitAndPush(fs)) - r.Post("/pull", PullChanges(fs)) + r.Route("/api/v1", func(r chi.Router) { + // User routes + r.Route("/users/{userId}", func(r chi.Router) { + r.Get("/", GetUser(db)) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", ListWorkspaces(db)) + r.Post("/", CreateWorkspace(db)) + r.Get("/last", GetLastWorkspace(db)) + r.Put("/last", UpdateLastWorkspace(db)) + + r.Route("/{workspaceId}", func(r chi.Router) { + r.Get("/", GetWorkspace(db)) + r.Put("/", UpdateWorkspace(db)) + r.Delete("/", DeleteWorkspace(db)) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", ListFiles(fs)) + r.Get("/last", GetLastOpenedFile(db)) + r.Put("/last", UpdateLastOpenedFile(db, fs)) + r.Get("/lookup", LookupFileByName(fs)) // Moved here + + r.Route("/*", func(r chi.Router) { + r.Post("/", SaveFile(fs)) + r.Get("/", GetFileContent(fs)) + r.Delete("/", DeleteFile(fs)) + }) + }) + + // Settings routes + r.Route("/settings", func(r chi.Router) { + r.Get("/", GetWorkspaceSettings(db)) + r.Put("/", UpdateWorkspaceSettings(db, fs)) + }) + + // Git routes + r.Route("/git", func(r chi.Router) { + r.Post("/commit", StageCommitAndPush(fs)) + r.Post("/pull", PullChanges(fs)) + }) + }) + }) }) }) } diff --git a/backend/internal/api/user_handlers.go b/backend/internal/api/user_handlers.go new file mode 100644 index 0000000..28cd9fe --- /dev/null +++ b/backend/internal/api/user_handlers.go @@ -0,0 +1,25 @@ +package api + +import ( + "net/http" + + "novamd/internal/db" +) + +func GetUser(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := db.GetUserByID(userID) + if err != nil { + http.Error(w, "Failed to get user", http.StatusInternalServerError) + return + } + + respondJSON(w, user) + } +} diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go new file mode 100644 index 0000000..6ae8019 --- /dev/null +++ b/backend/internal/api/workspace_handlers.go @@ -0,0 +1,246 @@ +package api + +import ( + "encoding/json" + "net/http" + + "novamd/internal/db" + "novamd/internal/filesystem" + "novamd/internal/models" +) + +func ListWorkspaces(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspaces, err := db.GetWorkspacesByUserID(userID) + if err != nil { + http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + return + } + + respondJSON(w, workspaces) + } +} + +func CreateWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + Name string `json:"name"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + workspace := &models.Workspace{ + UserID: userID, + Name: requestBody.Name, + } + + if err := db.CreateWorkspace(workspace); err != nil { + http.Error(w, "Failed to create workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, workspace) + } +} + +func GetWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspace, err := db.GetWorkspaceByID(workspaceID) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + if workspace.UserID != userID { + http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + return + } + + respondJSON(w, map[string]string{"name": workspace.Name}) + } +} + +func UpdateWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + Name string `json:"name"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + workspace, err := db.GetWorkspaceByID(workspaceID) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + if workspace.UserID != userID { + http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + return + } + + workspace.Name = requestBody.Name + if err := db.UpdateWorkspace(workspace); err != nil { + http.Error(w, "Failed to update workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"name": workspace.Name}) + } +} + +func DeleteWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspace, err := db.GetWorkspaceByID(workspaceID) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + if workspace.UserID != userID { + http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + return + } + + if err := db.DeleteWorkspace(workspaceID); err != nil { + http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Workspace deleted successfully")) + } +} + +func GetLastWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspaceID, err := db.GetLastWorkspaceID(userID) + if err != nil { + http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID}) + } +} + +func UpdateLastWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + WorkspaceID int `json:"workspaceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil { + http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) + } +} + +func GetWorkspaceSettings(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + settings, err := db.GetWorkspaceSettings(workspaceID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + respondJSON(w, settings) + } +} + +func UpdateWorkspaceSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var settings models.WorkspaceSettings + if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + settings.WorkspaceID = workspaceID + + if err := db.SaveWorkspaceSettings(&settings); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if settings.Settings.GitEnabled { + err := fs.SetupGitRepo(userID, workspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) + if err != nil { + http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) + return + } + } else { + fs.DisableGitRepo(userID, workspaceID) + } + + respondJSON(w, settings) + } +} diff --git a/backend/internal/db/workspace.go b/backend/internal/db/workspace.go index 0e8f3d4..1484e04 100644 --- a/backend/internal/db/workspace.go +++ b/backend/internal/db/workspace.go @@ -45,4 +45,15 @@ func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { workspaces = append(workspaces, workspace) } return workspaces, nil -} \ No newline at end of file +} + +func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { + _, err := db.Exec("UPDATE workspaces SET name = ? WHERE id = ? AND user_id = ?", + workspace.Name, workspace.ID, workspace.UserID) + return err +} + +func (db *DB) DeleteWorkspace(id int) error { + _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) + return err +} From 403ded509a3d409215336d3d01b323e09e1cdc8a Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Oct 2024 22:23:09 +0200 Subject: [PATCH 09/32] Fix main --- backend/cmd/server/main.go | 49 +++++++++++------------ backend/internal/filesystem/filesystem.go | 2 +- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fbdfb43..3cdf6d3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -36,17 +37,7 @@ func main() { workdir = "./data" } - settings, err := database.GetSettings(1) // Assuming user ID 1 for now - if err != nil { - log.Print("Settings not found, using default settings") - } - fs := filesystem.New(workdir, &settings) - - if settings.Settings.GitEnabled { - if err := fs.InitializeGitRepo(); err != nil { - log.Fatal(err) - } - } + fs := filesystem.New(workdir) // Set up router r := chi.NewRouter() @@ -64,21 +55,27 @@ func main() { staticPath = "../frontend/dist" } fileServer := http.FileServer(http.Dir(staticPath)) - r.Get("/*", func(w http.ResponseWriter, r *http.Request) { - requestedPath := r.URL.Path - validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath) - if err != nil { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } + r.Get( + "/*", + func(w http.ResponseWriter, r *http.Request) { + requestedPath := r.URL.Path - _, err = os.Stat(validatedPath) - if os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) - return - } - http.StripPrefix("/", fileServer).ServeHTTP(w, r) - }) + fullPath := filepath.Join(staticPath, requestedPath) + cleanPath := filepath.Clean(fullPath) + + if !strings.HasPrefix(cleanPath, staticPath) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + _, err = os.Stat(cleanPath) + if os.IsNotExist(err) { + http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) + return + } + http.StripPrefix("/", fileServer).ServeHTTP(w, r) + }, + ) // Start server port := os.Getenv("NOVAMD_PORT") @@ -87,4 +84,4 @@ func main() { } log.Printf("Server starting on port %s", port) log.Fatal(http.ListenAndServe(":"+port, r)) -} \ No newline at end of file +} diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index 3007f9d..5de8c27 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -194,4 +194,4 @@ func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bo } repo, ok := userRepos[workspaceID] return repo, ok -} \ No newline at end of file +} From 68ec134bf5623f903dd49b48f83e374ba80c6176 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Oct 2024 22:33:50 +0200 Subject: [PATCH 10/32] Fix migration error --- backend/internal/db/migrations.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index 48e4d02..a99b30a 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -34,10 +34,6 @@ var migrations = []Migration{ FOREIGN KEY (user_id) REFERENCES users (id) ); - -- Add foreign key constraint to users table - ALTER TABLE users ADD CONSTRAINT fk_last_workspace - FOREIGN KEY (last_workspace_id) REFERENCES workspaces (id); - -- Create workspace_settings table CREATE TABLE IF NOT EXISTS workspace_settings ( workspace_id INTEGER PRIMARY KEY, From a24f0d637c2ba0574dcdb88eabb30f85e93cd457 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Oct 2024 22:33:59 +0200 Subject: [PATCH 11/32] Fix chi error --- backend/internal/api/routes.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 058cff6..74334cc 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -32,11 +32,10 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { r.Put("/last", UpdateLastOpenedFile(db, fs)) r.Get("/lookup", LookupFileByName(fs)) // Moved here - r.Route("/*", func(r chi.Router) { - r.Post("/", SaveFile(fs)) - r.Get("/", GetFileContent(fs)) - r.Delete("/", DeleteFile(fs)) - }) + r.Post("/*", SaveFile(fs)) + r.Get("/*", GetFileContent(fs)) + r.Delete("/*", DeleteFile(fs)) + }) // Settings routes From 1df495230079b07bbe005f867306ad47779858bb Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 19 Oct 2024 12:07:45 +0200 Subject: [PATCH 12/32] Create workspace on user create --- backend/internal/db/user.go | 75 +++++++++++++++++++---- backend/internal/filesystem/filesystem.go | 27 ++++++++ backend/internal/models/user.go | 9 +++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/backend/internal/db/user.go b/backend/internal/db/user.go index ae913cd..830a12f 100644 --- a/backend/internal/db/user.go +++ b/backend/internal/db/user.go @@ -1,23 +1,76 @@ package db import ( + "database/sql" "novamd/internal/models" ) func (db *DB) CreateUser(user *models.User) error { - _, err := db.Exec(` - INSERT INTO users (email, display_name, password_hash) - VALUES (?, ?, ?)`, - user.Email, user.DisplayName, user.PasswordHash) - return err + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + result, err := tx.Exec(` + INSERT INTO users (email, display_name, password_hash, role) + VALUES (?, ?, ?, ?)`, + user.Email, user.DisplayName, user.PasswordHash, user.Role) + if err != nil { + return err + } + + userID, err := result.LastInsertId() + if err != nil { + return err + } + user.ID = int(userID) + + // Create default workspace + defaultWorkspace := &models.Workspace{ + UserID: user.ID, + Name: "Main", + } + err = db.createWorkspaceTx(tx, defaultWorkspace) + if err != nil { + return err + } + + // Update user's last workspace ID + _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + user.LastWorkspaceID = defaultWorkspace.ID + return nil +} + +func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { + result, err := tx.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?)", + workspace.UserID, workspace.Name) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + workspace.ID = int(id) + return nil } func (db *DB) GetUserByID(id int) (*models.User, error) { user := &models.User{} err := db.QueryRow(` - SELECT id, email, display_name, created_at, last_workspace_id, last_opened_file_path + SELECT id, email, display_name, role, created_at, last_workspace_id, last_opened_file_path FROM users WHERE id = ?`, id). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, + Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt, &user.LastWorkspaceID, &user.LastOpenedFilePath) if err != nil { return nil, err @@ -28,9 +81,9 @@ func (db *DB) GetUserByID(id int) (*models.User, error) { func (db *DB) GetUserByEmail(email string) (*models.User, error) { user := &models.User{} err := db.QueryRow(` - SELECT id, email, display_name, password_hash, created_at, last_workspace_id, last_opened_file_path + SELECT id, email, display_name, password_hash, role, created_at, last_workspace_id, last_opened_file_path FROM users WHERE email = ?`, email). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, + Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.LastWorkspaceID, &user.LastOpenedFilePath) if err != nil { return nil, err @@ -41,9 +94,9 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) { func (db *DB) UpdateUser(user *models.User) error { _, err := db.Exec(` UPDATE users - SET email = ?, display_name = ?, last_workspace_id = ?, last_opened_file_path = ? + SET email = ?, display_name = ?, role = ?, last_workspace_id = ?, last_opened_file_path = ? WHERE id = ?`, - user.Email, user.DisplayName, user.LastWorkspaceID, user.LastOpenedFilePath, user.ID) + user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.LastOpenedFilePath, user.ID) return err } diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index 5de8c27..c507293 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -32,6 +32,33 @@ func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID)) } +func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.MkdirAll(workspacePath, 0755) + if err != nil { + return fmt.Errorf("failed to create workspace directory: %w", err) + } + // Optionally, create a welcome file in the new workspace + // welcomeFilePath := filepath.Join(workspacePath, "Welcome.md") + // welcomeContent := []byte("# Welcome to Your Main Workspace\n\nThis is your default workspace in NovaMD. You can start creating and editing files right away!") + // err = os.WriteFile(welcomeFilePath, welcomeContent, 0644) + // if err != nil { + // return fmt.Errorf("failed to create welcome file: %w", err) + // } + + return nil +} + +func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.RemoveAll(workspacePath) + if err != nil { + return fmt.Errorf("failed to delete workspace directory: %w", err) + } + + return nil +} + func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { workspacePath := fs.GetWorkspacePath(userID, workspaceID) fullPath := filepath.Join(workspacePath, path) diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 49aa94d..ff8e599 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -4,11 +4,20 @@ import ( "time" ) +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleEditor UserRole = "editor" + RoleViewer UserRole = "viewer" +) + type User struct { ID int `json:"id" validate:"required,min=1"` Email string `json:"email" validate:"required,email"` DisplayName string `json:"displayName"` PasswordHash string `json:"-"` + Role UserRole `json:"role" validate:"required,oneof=admin editor viewer"` CreatedAt time.Time `json:"createdAt"` LastWorkspaceID int `json:"lastWorkspaceId"` LastOpenedFilePath string `json:"lastOpenedFilePath"` From 3b7bf83073bc1e628a589c5538c6c2dc00e31a16 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 19 Oct 2024 12:07:58 +0200 Subject: [PATCH 13/32] Set up admin user --- backend/cmd/server/main.go | 7 ++ backend/internal/db/migrations.go | 1 + backend/internal/user/user.go | 122 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 backend/internal/user/user.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3cdf6d3..d78ea94 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "novamd/internal/api" "novamd/internal/db" "novamd/internal/filesystem" + "novamd/internal/user" ) func main() { @@ -39,6 +40,12 @@ func main() { fs := filesystem.New(workdir) + // User service + userService := user.NewUserService(database, fs) + + // Admin user + userService.SetupAdminUser() + // Set up router r := chi.NewRouter() r.Use(middleware.Logger) diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index a99b30a..c6d234f 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -20,6 +20,7 @@ var migrations = []Migration{ email TEXT NOT NULL UNIQUE, display_name TEXT, password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_workspace_id INTEGER, last_opened_file_path TEXT diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go new file mode 100644 index 0000000..2f64e0d --- /dev/null +++ b/backend/internal/user/user.go @@ -0,0 +1,122 @@ +package user + +import ( + "fmt" + "log" + "os" + + "golang.org/x/crypto/bcrypt" + + "novamd/internal/db" + "novamd/internal/filesystem" + "novamd/internal/models" +) + +type UserService struct { + DB *db.DB + FS *filesystem.FileSystem +} + +func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService { + return &UserService{ + DB: database, + FS: fs, + } +} + +func (s *UserService) SetupAdminUser() (*models.User, error) { + // Get admin email and password from environment variables + adminEmail := os.Getenv("NOVAMD_ADMIN_EMAIL") + adminPassword := os.Getenv("NOVAMD_ADMIN_PASSWORD") + if adminEmail == "" || adminPassword == "" { + return nil, fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD environment variables must be set") + } + + // Check if admin user exists + adminUser, err := s.DB.GetUserByEmail(adminEmail) + if err == nil { + return adminUser, nil // Admin user already exists + } + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // Create admin user + adminUser = &models.User{ + Email: adminEmail, + DisplayName: "Admin", + PasswordHash: string(hashedPassword), + Role: models.RoleAdmin, + } + + err = s.DB.CreateUser(adminUser) + if err != nil { + return nil, fmt.Errorf("failed to create admin user: %w", err) + } + + err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID) + if err != nil { + return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) + } + + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID) + + return adminUser, nil +} + +func (s *UserService) CreateUser(user *models.User) error { + err := s.DB.CreateUser(user) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID) + if err != nil { + return fmt.Errorf("failed to initialize user workspace: %w", err) + } + + return nil +} + +func (s *UserService) GetUserByID(id int) (*models.User, error) { + return s.DB.GetUserByID(id) +} + +func (s *UserService) GetUserByEmail(email string) (*models.User, error) { + return s.DB.GetUserByEmail(email) +} + +func (s *UserService) UpdateUser(user *models.User) error { + return s.DB.UpdateUser(user) +} + +func (s *UserService) DeleteUser(id int) error { + // First, get the user to check if they exist and to get their workspaces + user, err := s.DB.GetUserByID(id) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Delete user's workspaces + workspaces, err := s.DB.GetWorkspacesByUserID(id) + if err != nil { + return fmt.Errorf("failed to get user's workspaces: %w", err) + } + + for _, workspace := range workspaces { + err = s.DB.DeleteWorkspace(workspace.ID) + if err != nil { + return fmt.Errorf("failed to delete workspace: %w", err) + } + err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID) + if err != nil { + return fmt.Errorf("failed to delete workspace files: %w", err) + } + } + + // Finally, delete the user + return s.DB.DeleteUser(id) +} From 6eb3eecb248f09cffa3072f2c3b3065c3d3a8449 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 19 Oct 2024 13:48:37 +0200 Subject: [PATCH 14/32] Add Workspace context --- frontend/src/App.js | 12 +- frontend/src/components/Editor.js | 4 +- frontend/src/components/FileActions.js | 4 +- frontend/src/components/Layout.js | 20 +- frontend/src/components/MainContent.js | 4 +- frontend/src/components/Settings.js | 4 +- frontend/src/components/Sidebar.js | 4 +- .../components/settings/AppearanceSettings.js | 4 +- frontend/src/contexts/SettingsContext.js | 79 ------ frontend/src/contexts/WorkspaceContext.js | 92 +++++++ frontend/src/hooks/useFileContent.js | 49 ++-- frontend/src/hooks/useFileList.js | 8 +- frontend/src/hooks/useFileNavigation.js | 8 +- frontend/src/hooks/useFileOperations.js | 25 +- frontend/src/hooks/useGitOperations.js | 19 +- frontend/src/services/api.js | 230 +++++++++++++----- 16 files changed, 356 insertions(+), 210 deletions(-) delete mode 100644 frontend/src/contexts/SettingsContext.js create mode 100644 frontend/src/contexts/WorkspaceContext.js diff --git a/frontend/src/App.js b/frontend/src/App.js index 5b3a52b..2ba651a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; import { ModalsProvider } from '@mantine/modals'; import Layout from './components/Layout'; -import { SettingsProvider, useSettings } from './contexts/SettingsContext'; +import { WorkspaceProvider } from './contexts/WorkspaceContext'; import { ModalProvider } from './contexts/ModalContext'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './App.scss'; function AppContent() { - const { loading } = useSettings(); - - if (loading) { - return
Loading...
; - } - return ; } @@ -26,11 +20,11 @@ function App() { - + - + diff --git a/frontend/src/components/Editor.js b/frontend/src/components/Editor.js index 1491f8e..91dd89f 100644 --- a/frontend/src/components/Editor.js +++ b/frontend/src/components/Editor.js @@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view'; import { markdown } from '@codemirror/lang-markdown'; import { defaultKeymap } from '@codemirror/commands'; import { oneDark } from '@codemirror/theme-one-dark'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { - const { settings } = useSettings(); + const { settings } = useWorkspace(); const editorRef = useRef(); const viewRef = useRef(); diff --git a/frontend/src/components/FileActions.js b/frontend/src/components/FileActions.js index 5984a89..b387022 100644 --- a/frontend/src/components/FileActions.js +++ b/frontend/src/components/FileActions.js @@ -6,11 +6,11 @@ import { IconGitPullRequest, IconGitCommit, } from '@tabler/icons-react'; -import { useSettings } from '../contexts/SettingsContext'; import { useModalContext } from '../contexts/ModalContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const FileActions = ({ handlePullChanges, selectedFile }) => { - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { setNewFileModalVisible, setDeleteFileModalVisible, diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index 97df9e8..ef012f2 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,16 +1,30 @@ import React from 'react'; -import { AppShell, Container } from '@mantine/core'; +import { AppShell, Container, Loader, Center } from '@mantine/core'; import Header from './Header'; import Sidebar from './Sidebar'; import MainContent from './MainContent'; import { useFileNavigation } from '../hooks/useFileNavigation'; import { useFileList } from '../hooks/useFileList'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Layout = () => { + const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { selectedFile, handleFileSelect, handleLinkClick } = useFileNavigation(); const { files, loadFileList } = useFileList(); + if (workspaceLoading) { + return ( +
+ +
+ ); + } + + if (!currentWorkspace) { + return
No workspace found. Please create a workspace.
; + } + return ( @@ -22,8 +36,8 @@ const Layout = () => { p={0} style={{ display: 'flex', - height: 'calc(100vh - 60px - 2rem)', // Subtracting header height and vertical padding - overflow: 'hidden', // Prevent scrolling in the container + height: 'calc(100vh - 60px - 2rem)', + overflow: 'hidden', }} > { const [activeTab, setActiveTab] = useState('source'); - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { content, hasUnsavedChanges, diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index b0e10ce..7526254 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -1,7 +1,7 @@ import React, { useReducer, useEffect, useCallback, useRef } from 'react'; import { Modal, Badge, Button, Group, Title } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; import AppearanceSettings from './settings/AppearanceSettings'; import EditorSettings from './settings/EditorSettings'; import GitSettings from './settings/GitSettings'; @@ -50,7 +50,7 @@ function settingsReducer(state, action) { } const Settings = () => { - const { settings, updateSettings, colorScheme } = useSettings(); + const { settings, updateSettings, colorScheme } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const [state, dispatch] = useReducer(settingsReducer, initialState); const isInitialMount = useRef(true); diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 79271f7..04619c7 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -3,10 +3,10 @@ import { Box } from '@mantine/core'; import FileActions from './FileActions'; import FileTree from './FileTree'; import { useGitOperations } from '../hooks/useGitOperations'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { handlePull } = useGitOperations(settings.gitEnabled); useEffect(() => { diff --git a/frontend/src/components/settings/AppearanceSettings.js b/frontend/src/components/settings/AppearanceSettings.js index f29f253..2073c4f 100644 --- a/frontend/src/components/settings/AppearanceSettings.js +++ b/frontend/src/components/settings/AppearanceSettings.js @@ -1,9 +1,9 @@ import React from 'react'; import { Text, Switch, Group, Box, Title } from '@mantine/core'; -import { useSettings } from '../../contexts/SettingsContext'; +import { useWorkspace } from '../../contexts/WorkspaceContext'; const AppearanceSettings = ({ onThemeChange }) => { - const { colorScheme, toggleColorScheme } = useSettings(); + const { colorScheme, toggleColorScheme } = useWorkspace(); const handleThemeChange = () => { toggleColorScheme(); diff --git a/frontend/src/contexts/SettingsContext.js b/frontend/src/contexts/SettingsContext.js deleted file mode 100644 index 0a8df8d..0000000 --- a/frontend/src/contexts/SettingsContext.js +++ /dev/null @@ -1,79 +0,0 @@ -import React, { - createContext, - useContext, - useEffect, - useMemo, - useCallback, - useState, -} from 'react'; -import { useMantineColorScheme } from '@mantine/core'; -import { fetchUserSettings, saveUserSettings } from '../services/api'; -import { DEFAULT_SETTINGS } from '../utils/constants'; - -const SettingsContext = createContext(); - -export const useSettings = () => useContext(SettingsContext); - -export const SettingsProvider = ({ children }) => { - const { colorScheme, setColorScheme } = useMantineColorScheme(); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadSettings = async () => { - try { - const userSettings = await fetchUserSettings(1); - setSettings(userSettings.settings); - setColorScheme(userSettings.settings.theme); - } catch (error) { - console.error('Failed to load user settings:', error); - } finally { - setLoading(false); - } - }; - - loadSettings(); - }, []); - - const updateSettings = useCallback( - async (newSettings) => { - try { - await saveUserSettings({ - userId: 1, - settings: newSettings, - }); - setSettings(newSettings); - if (newSettings.theme) { - setColorScheme(newSettings.theme); - } - } catch (error) { - console.error('Failed to save settings:', error); - throw error; - } - }, - [setColorScheme] - ); - - const toggleColorScheme = useCallback(() => { - const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; - setColorScheme(newTheme); - updateSettings({ ...settings, theme: newTheme }); - }, [colorScheme, settings, setColorScheme, updateSettings]); - - const contextValue = useMemo( - () => ({ - settings, - updateSettings, - toggleColorScheme, - loading, - colorScheme, - }), - [settings, updateSettings, toggleColorScheme, loading, colorScheme] - ); - - return ( - - {children} - - ); -}; diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js new file mode 100644 index 0000000..41b4e46 --- /dev/null +++ b/frontend/src/contexts/WorkspaceContext.js @@ -0,0 +1,92 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from 'react'; +import { useMantineColorScheme } from '@mantine/core'; +import { + fetchLastWorkspace, + fetchWorkspaceSettings, + saveWorkspaceSettings, +} from '../services/api'; +import { DEFAULT_SETTINGS } from '../utils/constants'; + +const WorkspaceContext = createContext(); + +export const WorkspaceProvider = ({ children }) => { + const [currentWorkspace, setCurrentWorkspace] = useState(null); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + + useEffect(() => { + const loadWorkspaceAndSettings = async () => { + try { + const workspace = await fetchLastWorkspace(); + setCurrentWorkspace(workspace); + + if (workspace) { + const workspaceSettings = await fetchWorkspaceSettings(workspace.id); + setSettings(workspaceSettings.settings); + setColorScheme(workspaceSettings.settings.theme); + } + } catch (error) { + console.error('Failed to load workspace or settings:', error); + } finally { + setLoading(false); + } + }; + + loadWorkspaceAndSettings(); + }, [setColorScheme]); + + const updateSettings = useCallback( + async (newSettings) => { + if (!currentWorkspace) return; + + try { + await saveWorkspaceSettings(currentWorkspace.id, newSettings); + setSettings(newSettings); + if (newSettings.theme) { + setColorScheme(newSettings.theme); + } + } catch (error) { + console.error('Failed to save settings:', error); + throw error; + } + }, + [currentWorkspace, setColorScheme] + ); + + const toggleColorScheme = useCallback(() => { + const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; + setColorScheme(newTheme); + updateSettings({ ...settings, theme: newTheme }); + }, [colorScheme, settings, setColorScheme, updateSettings]); + + const value = { + currentWorkspace, + setCurrentWorkspace, + settings, + updateSettings, + toggleColorScheme, + loading, + colorScheme, + }; + + return ( + + {children} + + ); +}; + +export const useWorkspace = () => { + const context = useContext(WorkspaceContext); + if (context === undefined) { + throw new Error('useWorkspace must be used within a WorkspaceProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/useFileContent.js b/frontend/src/hooks/useFileContent.js index ba12b22..cb304d0 100644 --- a/frontend/src/hooks/useFileContent.js +++ b/frontend/src/hooks/useFileContent.js @@ -2,38 +2,45 @@ import { useState, useCallback, useEffect } from 'react'; import { fetchFileContent } from '../services/api'; import { isImageFile } from '../utils/fileHelpers'; import { DEFAULT_FILE } from '../utils/constants'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileContent = (selectedFile) => { + const { currentWorkspace } = useWorkspace(); const [content, setContent] = useState(DEFAULT_FILE.content); const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const loadFileContent = useCallback(async (filePath) => { - try { - let newContent; - if (filePath === DEFAULT_FILE.path) { - newContent = DEFAULT_FILE.content; - } else if (!isImageFile(filePath)) { - newContent = await fetchFileContent(filePath); - } else { - newContent = ''; // Set empty content for image files + const loadFileContent = useCallback( + async (filePath) => { + if (!currentWorkspace) return; + + try { + let newContent; + if (filePath === DEFAULT_FILE.path) { + newContent = DEFAULT_FILE.content; + } else if (!isImageFile(filePath)) { + newContent = await fetchFileContent(currentWorkspace.id, filePath); + } else { + newContent = ''; // Set empty content for image files + } + setContent(newContent); + setOriginalContent(newContent); + setHasUnsavedChanges(false); + } catch (err) { + console.error('Error loading file content:', err); + setContent(''); // Set empty content on error + setOriginalContent(''); + setHasUnsavedChanges(false); } - setContent(newContent); - setOriginalContent(newContent); - setHasUnsavedChanges(false); - } catch (err) { - console.error('Error loading file content:', err); - setContent(''); // Set empty content on error - setOriginalContent(''); - setHasUnsavedChanges(false); - } - }, []); + }, + [currentWorkspace] + ); useEffect(() => { - if (selectedFile) { + if (selectedFile && currentWorkspace) { loadFileContent(selectedFile); } - }, [selectedFile, loadFileContent]); + }, [selectedFile, currentWorkspace, loadFileContent]); const handleContentChange = useCallback( (newContent) => { diff --git a/frontend/src/hooks/useFileList.js b/frontend/src/hooks/useFileList.js index 2e8abed..36171a8 100644 --- a/frontend/src/hooks/useFileList.js +++ b/frontend/src/hooks/useFileList.js @@ -1,12 +1,16 @@ import { useState, useCallback } from 'react'; import { fetchFileList } from '../services/api'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileList = () => { const [files, setFiles] = useState([]); + const { currentWorkspace } = useWorkspace(); const loadFileList = useCallback(async () => { + if (!currentWorkspace) return; + try { - const fileList = await fetchFileList(); + const fileList = await fetchFileList(currentWorkspace.id); if (Array.isArray(fileList)) { setFiles(fileList); } else { @@ -15,7 +19,7 @@ export const useFileList = () => { } catch (error) { console.error('Failed to load file list:', error); } - }, []); + }, [currentWorkspace]); return { files, loadFileList }; }; diff --git a/frontend/src/hooks/useFileNavigation.js b/frontend/src/hooks/useFileNavigation.js index 497f1e4..7c3623d 100644 --- a/frontend/src/hooks/useFileNavigation.js +++ b/frontend/src/hooks/useFileNavigation.js @@ -2,10 +2,12 @@ import { useState, useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { lookupFileByName } from '../services/api'; import { DEFAULT_FILE } from '../utils/constants'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileNavigation = () => { const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); const [isNewFile, setIsNewFile] = useState(true); + const { currentWorkspace } = useWorkspace(); const handleFileSelect = useCallback((filePath) => { setSelectedFile(filePath); @@ -14,8 +16,10 @@ export const useFileNavigation = () => { const handleLinkClick = useCallback( async (filename) => { + if (!currentWorkspace) return; + try { - const filePaths = await lookupFileByName(filename); + const filePaths = await lookupFileByName(currentWorkspace.id, filename); if (filePaths.length >= 1) { handleFileSelect(filePaths[0]); } else { @@ -34,7 +38,7 @@ export const useFileNavigation = () => { }); } }, - [handleFileSelect] + [currentWorkspace, handleFileSelect] ); return { handleLinkClick, selectedFile, isNewFile, handleFileSelect }; diff --git a/frontend/src/hooks/useFileOperations.js b/frontend/src/hooks/useFileOperations.js index a76b5b4..4b7f70a 100644 --- a/frontend/src/hooks/useFileOperations.js +++ b/frontend/src/hooks/useFileOperations.js @@ -1,12 +1,12 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { saveFileContent, deleteFile } from '../services/api'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; import { useGitOperations } from './useGitOperations'; export const useFileOperations = () => { - const { settings } = useSettings(); - const { handleCommitAndPush } = useGitOperations(settings.gitEnabled); + const { currentWorkspace, settings } = useWorkspace(); + const { handleCommitAndPush } = useGitOperations(); const autoCommit = useCallback( async (filePath, action) => { @@ -15,7 +15,6 @@ export const useFileOperations = () => { .replace('${filename}', filePath) .replace('${action}', action); - // Capitalize the first letter of the commit message commitMessage = commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1); @@ -27,8 +26,10 @@ export const useFileOperations = () => { const handleSave = useCallback( async (filePath, content) => { + if (!currentWorkspace) return false; + try { - await saveFileContent(filePath, content); + await saveFileContent(currentWorkspace.id, filePath, content); notifications.show({ title: 'Success', message: 'File saved successfully', @@ -46,13 +47,15 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); const handleDelete = useCallback( async (filePath) => { + if (!currentWorkspace) return false; + try { - await deleteFile(filePath); + await deleteFile(currentWorkspace.id, filePath); notifications.show({ title: 'Success', message: 'File deleted successfully', @@ -70,13 +73,15 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); const handleCreate = useCallback( async (fileName, initialContent = '') => { + if (!currentWorkspace) return false; + try { - await saveFileContent(fileName, initialContent); + await saveFileContent(currentWorkspace.id, fileName, initialContent); notifications.show({ title: 'Success', message: 'File created successfully', @@ -94,7 +99,7 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); return { handleSave, handleDelete, handleCreate }; diff --git a/frontend/src/hooks/useGitOperations.js b/frontend/src/hooks/useGitOperations.js index 63f2424..40e1669 100644 --- a/frontend/src/hooks/useGitOperations.js +++ b/frontend/src/hooks/useGitOperations.js @@ -1,12 +1,16 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { pullChanges, commitAndPush } from '../services/api'; +import { useWorkspace } from '../contexts/WorkspaceContext'; + +export const useGitOperations = () => { + const { currentWorkspace, settings } = useWorkspace(); -export const useGitOperations = (gitEnabled) => { const handlePull = useCallback(async () => { - if (!gitEnabled) return false; + if (!currentWorkspace || !settings.gitEnabled) return false; + try { - await pullChanges(); + await pullChanges(currentWorkspace.id); notifications.show({ title: 'Success', message: 'Successfully pulled latest changes', @@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => { }); return false; } - }, [gitEnabled]); + }, [currentWorkspace, settings.gitEnabled]); const handleCommitAndPush = useCallback( async (message) => { - if (!gitEnabled) return false; + if (!currentWorkspace || !settings.gitEnabled) return false; + try { - await commitAndPush(message); + await commitAndPush(currentWorkspace.id, message); notifications.show({ title: 'Success', message: 'Successfully committed and pushed changes', @@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => { return false; } }, - [gitEnabled] + [currentWorkspace, settings.gitEnabled] ); return { handlePull, handleCommitAndPush }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 8b57ad5..66c1cad 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -16,76 +16,176 @@ const apiCall = async (url, options = {}) => { } }; -export const fetchFileList = async () => { - const response = await apiCall(`${API_BASE_URL}/files`); +export const fetchLastWorkspace = async () => { + const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`); return response.json(); }; -export const fetchFileContent = async (filePath) => { - const response = await apiCall(`${API_BASE_URL}/files/${filePath}`); - return response.text(); -}; - -export const saveFileContent = async (filePath, content) => { - const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - body: content, - }); - return response.text(); -}; - -export const deleteFile = async (filePath) => { - const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, { - method: 'DELETE', - }); - return response.text(); -}; - -export const fetchUserSettings = async (userId) => { - const response = await apiCall(`${API_BASE_URL}/settings?userId=${userId}`); - return response.json(); -}; - -export const saveUserSettings = async (settings) => { - const response = await apiCall(`${API_BASE_URL}/settings`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(settings), - }); - return response.json(); -}; - -export const pullChanges = async () => { - const response = await apiCall(`${API_BASE_URL}/git/pull`, { - method: 'POST', - }); - return response.json(); -}; - -export const commitAndPush = async (message) => { - const response = await apiCall(`${API_BASE_URL}/git/commit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message }), - }); - return response.json(); -}; - -export const getFileUrl = (filePath) => { - return `${API_BASE_URL}/files/${filePath}`; -}; - -export const lookupFileByName = async (filename) => { +export const fetchFileList = async (workspaceId) => { const response = await apiCall( - `${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}` + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files` + ); + return response.json(); +}; + +export const fetchFileContent = async (workspaceId, filePath) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}` + ); + return response.text(); +}; + +export const saveFileContent = async (workspaceId, filePath, content) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: content, + } + ); + return response.text(); +}; + +export const deleteFile = async (workspaceId, filePath) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`, + { + method: 'DELETE', + } + ); + return response.text(); +}; + +export const fetchWorkspaceSettings = async (workspaceId) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/settings` + ); + return response.json(); +}; + +export const saveWorkspaceSettings = async (workspaceId, settings) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/settings`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ settings }), + } + ); + return response.json(); +}; + +export const pullChanges = async (workspaceId) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/pull`, + { + method: 'POST', + } + ); + return response.json(); +}; + +export const commitAndPush = async (workspaceId, message) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/commit`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message }), + } + ); + return response.json(); +}; + +export const getFileUrl = (workspaceId, filePath) => { + return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`; +}; + +export const lookupFileByName = async (workspaceId, filename) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent( + filename + )}` ); const data = await response.json(); return data.paths; }; + +export const updateLastOpenedFile = async (workspaceId, filePath) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filePath }), + } + ); + return response.json(); +}; + +export const getLastOpenedFile = async (workspaceId) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last` + ); + return response.json(); +}; + +export const listWorkspaces = async () => { + const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`); + return response.json(); +}; + +export const createWorkspace = async (name) => { + const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }); + return response.json(); +}; + +export const updateWorkspace = async (workspaceId, name) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + } + ); + return response.json(); +}; + +export const deleteWorkspace = async (workspaceId) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, + { + method: 'DELETE', + } + ); + return response.json(); +}; + +export const updateLastWorkspace = async (workspaceId) => { + const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId }), + }); + return response.json(); +}; From 2f87d87833bcd4f286ed114cbba69f4053a2ca40 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 19 Oct 2024 13:49:11 +0200 Subject: [PATCH 15/32] Fix admin user creation --- backend/cmd/server/main.go | 5 ++++- backend/internal/db/user.go | 8 +++++++- backend/internal/user/user.go | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d78ea94..82595b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -44,7 +44,10 @@ func main() { userService := user.NewUserService(database, fs) // Admin user - userService.SetupAdminUser() + _, err = userService.SetupAdminUser() + if err != nil { + log.Fatal(err) + } // Set up router r := chi.NewRouter() diff --git a/backend/internal/db/user.go b/backend/internal/db/user.go index 830a12f..1e388dc 100644 --- a/backend/internal/db/user.go +++ b/backend/internal/db/user.go @@ -80,14 +80,20 @@ func (db *DB) GetUserByID(id int) (*models.User, error) { func (db *DB) GetUserByEmail(email string) (*models.User, error) { user := &models.User{} + var lastOpenedFilePath sql.NullString err := db.QueryRow(` SELECT id, email, display_name, password_hash, role, created_at, last_workspace_id, last_opened_file_path FROM users WHERE email = ?`, email). Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, - &user.LastWorkspaceID, &user.LastOpenedFilePath) + &user.LastWorkspaceID, &lastOpenedFilePath) if err != nil { return nil, err } + if lastOpenedFilePath.Valid { + user.LastOpenedFilePath = lastOpenedFilePath.String + } else { + user.LastOpenedFilePath = "" + } return user, nil } diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go index 2f64e0d..ee23702 100644 --- a/backend/internal/user/user.go +++ b/backend/internal/user/user.go @@ -34,7 +34,7 @@ func (s *UserService) SetupAdminUser() (*models.User, error) { // Check if admin user exists adminUser, err := s.DB.GetUserByEmail(adminEmail) - if err == nil { + if adminUser != nil { return adminUser, nil // Admin user already exists } From 749461f11b4dbf2584b682aa3e2b110f775a8da5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 19 Oct 2024 14:10:49 +0200 Subject: [PATCH 16/32] Fix router --- backend/internal/api/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 74334cc..fcf25cd 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -8,7 +8,7 @@ import ( ) func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { - r.Route("/api/v1", func(r chi.Router) { + r.Route("/", func(r chi.Router) { // User routes r.Route("/users/{userId}", func(r chi.Router) { r.Get("/", GetUser(db)) From ffe82b335a16f901dc1ad9edcd0b111214f16021 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 21 Oct 2024 23:48:50 +0200 Subject: [PATCH 17/32] Get full workspace object --- backend/internal/api/workspace_handlers.go | 2 +- backend/internal/db/workspace.go | 4 ++-- frontend/src/services/api.js | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go index 6ae8019..f9cbfc2 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/api/workspace_handlers.go @@ -77,7 +77,7 @@ func GetWorkspace(db *db.DB) http.HandlerFunc { return } - respondJSON(w, map[string]string{"name": workspace.Name}) + respondJSON(w, workspace) } } diff --git a/backend/internal/db/workspace.go b/backend/internal/db/workspace.go index 1484e04..98cf07c 100644 --- a/backend/internal/db/workspace.go +++ b/backend/internal/db/workspace.go @@ -20,7 +20,7 @@ func (db *DB) CreateWorkspace(workspace *models.Workspace) error { func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { workspace := &models.Workspace{} - err := db.QueryRow("SELECT id, user_id, name, root_path, created_at FROM workspaces WHERE id = ?", id). + err := db.QueryRow("SELECT id, user_id, name, created_at FROM workspaces WHERE id = ?", id). Scan(&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt) if err != nil { return nil, err @@ -29,7 +29,7 @@ func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { } func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { - rows, err := db.Query("SELECT id, user_id, name, root_path, created_at FROM workspaces WHERE user_id = ?", userID) + rows, err := db.Query("SELECT id, user_id, name, created_at FROM workspaces WHERE user_id = ?", userID) if err != nil { return nil, err } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 66c1cad..892f73f 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -16,7 +16,7 @@ const apiCall = async (url, options = {}) => { } }; -export const fetchLastWorkspace = async () => { +export const fetchLastWorkspaceId = async () => { const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`); return response.json(); }; @@ -59,6 +59,13 @@ export const deleteFile = async (workspaceId, filePath) => { return response.text(); }; +export const getWorkspace = async (workspaceId) => { + const response = await apiCall( + `${API_BASE_URL}/users/1/workspaces/${workspaceId}` + ); + return response.json(); +}; + export const fetchWorkspaceSettings = async (workspaceId) => { const response = await apiCall( `${API_BASE_URL}/users/1/workspaces/${workspaceId}/settings` From 1c59f8da4fc3a6d6d0e74ed050478417d0226e2d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 22 Oct 2024 00:11:03 +0200 Subject: [PATCH 18/32] Fix file list loading --- backend/internal/filesystem/filesystem.go | 2 +- frontend/src/contexts/WorkspaceContext.js | 29 +++++++++++++---------- frontend/src/hooks/useFileList.js | 9 +++---- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index c507293..6e01536 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -82,7 +82,7 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { return nil, err } - var nodes []FileNode + nodes := make([]FileNode, 0) for _, entry := range entries { name := entry.Name() path := filepath.Join(prefix, name) diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index 41b4e46..5531109 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -7,9 +7,10 @@ import React, { } from 'react'; import { useMantineColorScheme } from '@mantine/core'; import { - fetchLastWorkspace, + fetchLastWorkspaceId, fetchWorkspaceSettings, saveWorkspaceSettings, + getWorkspace, } from '../services/api'; import { DEFAULT_SETTINGS } from '../utils/constants'; @@ -22,25 +23,30 @@ export const WorkspaceProvider = ({ children }) => { const { colorScheme, setColorScheme } = useMantineColorScheme(); useEffect(() => { - const loadWorkspaceAndSettings = async () => { + const loadWorkspace = async () => { try { - const workspace = await fetchLastWorkspace(); - setCurrentWorkspace(workspace); - - if (workspace) { - const workspaceSettings = await fetchWorkspaceSettings(workspace.id); + const { lastWorkspaceId } = await fetchLastWorkspaceId(); + if (lastWorkspaceId) { + const workspace = await getWorkspace(lastWorkspaceId); + console.log('Workspace: ', workspace); + setCurrentWorkspace(workspace); + const workspaceSettings = await fetchWorkspaceSettings( + lastWorkspaceId + ); setSettings(workspaceSettings.settings); setColorScheme(workspaceSettings.settings.theme); + } else { + console.warn('No last workspace found'); } } catch (error) { - console.error('Failed to load workspace or settings:', error); + console.error('Failed to initialize workspace:', error); } finally { setLoading(false); } }; - loadWorkspaceAndSettings(); - }, [setColorScheme]); + loadWorkspace(); + }, []); const updateSettings = useCallback( async (newSettings) => { @@ -68,12 +74,11 @@ export const WorkspaceProvider = ({ children }) => { const value = { currentWorkspace, - setCurrentWorkspace, settings, updateSettings, - toggleColorScheme, loading, colorScheme, + toggleColorScheme, }; return ( diff --git a/frontend/src/hooks/useFileList.js b/frontend/src/hooks/useFileList.js index 36171a8..46ca9ec 100644 --- a/frontend/src/hooks/useFileList.js +++ b/frontend/src/hooks/useFileList.js @@ -1,13 +1,13 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { fetchFileList } from '../services/api'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileList = () => { const [files, setFiles] = useState([]); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const loadFileList = useCallback(async () => { - if (!currentWorkspace) return; + if (!currentWorkspace || workspaceLoading) return; try { const fileList = await fetchFileList(currentWorkspace.id); @@ -18,8 +18,9 @@ export const useFileList = () => { } } catch (error) { console.error('Failed to load file list:', error); + setFiles([]); } - }, [currentWorkspace]); + }, [currentWorkspace, workspaceLoading]); return { files, loadFileList }; }; From 4ade504b5b5147cf2e84e5f0eb0f88d7b2a4e79b Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 22 Oct 2024 18:32:33 +0200 Subject: [PATCH 19/32] Run go mod tidy --- backend/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/go.mod b/backend/go.mod index 9fb7aeb..3cea35d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 github.com/mattn/go-sqlite3 v1.14.23 + golang.org/x/crypto v0.21.0 ) require ( @@ -29,7 +30,6 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect From a231fc48c2779c53cf047758a0c842186b1a0a67 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 22 Oct 2024 18:32:46 +0200 Subject: [PATCH 20/32] Remove debug print --- frontend/src/contexts/WorkspaceContext.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index 5531109..ddcf6d3 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -28,7 +28,6 @@ export const WorkspaceProvider = ({ children }) => { const { lastWorkspaceId } = await fetchLastWorkspaceId(); if (lastWorkspaceId) { const workspace = await getWorkspace(lastWorkspaceId); - console.log('Workspace: ', workspace); setCurrentWorkspace(workspace); const workspaceSettings = await fetchWorkspaceSettings( lastWorkspaceId From 05dd3a83b05447e8775bb850a62e09990a075cc1 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 22 Oct 2024 21:21:18 +0200 Subject: [PATCH 21/32] Fix settings saving --- backend/internal/api/workspace_handlers.go | 22 +++++++++++----- backend/internal/user/user.go | 26 +++++++++++++++++++ frontend/src/components/Editor.js | 12 ++++----- frontend/src/components/Layout.js | 4 +-- frontend/src/components/Settings.js | 20 ++------------ .../components/settings/AppearanceSettings.js | 9 ++++--- frontend/src/contexts/WorkspaceContext.js | 18 ++++++------- frontend/src/services/api.js | 2 +- 8 files changed, 66 insertions(+), 47 deletions(-) diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go index f9cbfc2..3677777 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/api/workspace_handlers.go @@ -218,21 +218,29 @@ func UpdateWorkspaceSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerF return } - var settings models.WorkspaceSettings - if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { + var userSettings models.UserSettings + if err := json.NewDecoder(r.Body).Decode(&userSettings); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := userSettings.Validate(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - settings.WorkspaceID = workspaceID + workspaceSettings := &models.WorkspaceSettings{ + WorkspaceID: workspaceID, + Settings: userSettings, + } - if err := db.SaveWorkspaceSettings(&settings); err != nil { + if err := db.SaveWorkspaceSettings(workspaceSettings); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if settings.Settings.GitEnabled { - err := fs.SetupGitRepo(userID, workspaceID, settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) + if userSettings.GitEnabled { + err := fs.SetupGitRepo(userID, workspaceID, userSettings.GitURL, userSettings.GitUser, userSettings.GitToken) if err != nil { http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) return @@ -241,6 +249,6 @@ func UpdateWorkspaceSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerF fs.DisableGitRepo(userID, workspaceID) } - respondJSON(w, settings) + respondJSON(w, workspaceSettings) } } diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go index ee23702..ed7d8e5 100644 --- a/backend/internal/user/user.go +++ b/backend/internal/user/user.go @@ -17,6 +17,17 @@ type UserService struct { FS *filesystem.FileSystem } +// Default settings for new workspaces +var defaultWorkspaceSettings = models.WorkspaceSettings{ + Settings: models.UserSettings{ + Theme: "light", + AutoSave: false, + GitEnabled: false, + GitAutoCommit: false, + GitCommitMsgTemplate: "${action} ${filename}", + }, +} + func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService { return &UserService{ DB: database, @@ -62,6 +73,13 @@ func (s *UserService) SetupAdminUser() (*models.User, error) { return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) } + // Save default settings for the admin workspace + defaultWorkspaceSettings.WorkspaceID = adminUser.LastWorkspaceID + err = s.DB.SaveWorkspaceSettings(&defaultWorkspaceSettings) + if err != nil { + return nil, fmt.Errorf("failed to save default workspace settings: %w", err) + } + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID) return adminUser, nil @@ -78,6 +96,14 @@ func (s *UserService) CreateUser(user *models.User) error { return fmt.Errorf("failed to initialize user workspace: %w", err) } + // Save default settings for the user's workspace + settings := defaultWorkspaceSettings + settings.WorkspaceID = user.LastWorkspaceID + err = s.DB.SaveWorkspaceSettings(&settings) + if err != nil { + return fmt.Errorf("failed to save default workspace settings: %w", err) + } + return nil } diff --git a/frontend/src/components/Editor.js b/frontend/src/components/Editor.js index 91dd89f..902bb9b 100644 --- a/frontend/src/components/Editor.js +++ b/frontend/src/components/Editor.js @@ -8,7 +8,7 @@ import { oneDark } from '@codemirror/theme-one-dark'; import { useWorkspace } from '../contexts/WorkspaceContext'; const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { - const { settings } = useWorkspace(); + const { colorScheme } = useWorkspace(); const editorRef = useRef(); const viewRef = useRef(); @@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { overflow: 'auto', }, '.cm-gutters': { - backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5', - color: settings.theme === 'dark' ? '#858585' : '#999', + backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5', + color: colorScheme === 'dark' ? '#858585' : '#999', border: 'none', }, '.cm-activeLineGutter': { - backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8', + backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8', }, }); @@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { } }), theme, - settings.theme === 'dark' ? oneDark : [], + colorScheme === 'dark' ? oneDark : [], ], }); @@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { return () => { view.destroy(); }; - }, [settings.theme, handleContentChange]); + }, [colorScheme, handleContentChange]); useEffect(() => { if (viewRef.current && content !== viewRef.current.state.doc.toString()) { diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index ef012f2..6f2c5ed 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -36,8 +36,8 @@ const Layout = () => { p={0} style={{ display: 'flex', - height: 'calc(100vh - 60px - 2rem)', - overflow: 'hidden', + height: 'calc(100vh - 60px - 2rem)', // Subtracting header height and vertical padding + overflow: 'hidden', // Prevent scrolling in the container }} > { - const { settings, updateSettings, colorScheme } = useWorkspace(); + const { settings, updateSettings } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const [state, dispatch] = useReducer(settingsReducer, initialState); const isInitialMount = useRef(true); @@ -62,13 +56,6 @@ const Settings = () => { } }, [settings]); - useEffect(() => { - dispatch({ - type: 'UPDATE_LOCAL_SETTINGS', - payload: { theme: colorScheme }, - }); - }, [colorScheme]); - const handleInputChange = useCallback((key, value) => { dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); }, []); @@ -92,11 +79,8 @@ const Settings = () => { }; const handleClose = useCallback(() => { - if (state.hasUnsavedChanges) { - dispatch({ type: 'RESET' }); - } setSettingsModalVisible(false); - }, [state.hasUnsavedChanges, setSettingsModalVisible]); + }, [setSettingsModalVisible]); return ( { - const { colorScheme, toggleColorScheme } = useWorkspace(); +const AppearanceSettings = ({ themeSettings, onThemeChange }) => { + const { colorScheme, updateColorScheme } = useWorkspace(); const handleThemeChange = () => { - toggleColorScheme(); - onThemeChange(colorScheme === 'dark' ? 'light' : 'dark'); + const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; + updateColorScheme(newTheme); + onThemeChange(newTheme); }; return ( diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index ddcf6d3..f228b0c 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -54,9 +54,7 @@ export const WorkspaceProvider = ({ children }) => { try { await saveWorkspaceSettings(currentWorkspace.id, newSettings); setSettings(newSettings); - if (newSettings.theme) { - setColorScheme(newSettings.theme); - } + setColorScheme(newSettings.theme); } catch (error) { console.error('Failed to save settings:', error); throw error; @@ -65,11 +63,13 @@ export const WorkspaceProvider = ({ children }) => { [currentWorkspace, setColorScheme] ); - const toggleColorScheme = useCallback(() => { - const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; - setColorScheme(newTheme); - updateSettings({ ...settings, theme: newTheme }); - }, [colorScheme, settings, setColorScheme, updateSettings]); + // Update just the color scheme without saving to backend + const updateColorScheme = useCallback( + (newTheme) => { + setColorScheme(newTheme); + }, + [setColorScheme] + ); const value = { currentWorkspace, @@ -77,7 +77,7 @@ export const WorkspaceProvider = ({ children }) => { updateSettings, loading, colorScheme, - toggleColorScheme, + updateColorScheme, }; return ( diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 892f73f..58ab827 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -81,7 +81,7 @@ export const saveWorkspaceSettings = async (workspaceId, settings) => { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ settings }), + body: JSON.stringify(settings), } ); return response.json(); From fd313c1d7f94aa6c9edf24a6b185009b4cf2710d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 22 Oct 2024 21:47:10 +0200 Subject: [PATCH 22/32] Add Workspace menu --- frontend/src/components/Header.js | 13 +--- frontend/src/components/WorkspaceMenu.js | 77 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/WorkspaceMenu.js diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index f95b52a..0fbe38d 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,14 +1,9 @@ import React from 'react'; -import { Group, Text, ActionIcon, Avatar } from '@mantine/core'; -import { IconSettings } from '@tabler/icons-react'; +import { Group, Text, Avatar } from '@mantine/core'; +import WorkspaceMenu from './WorkspaceMenu'; import Settings from './Settings'; -import { useModalContext } from '../contexts/ModalContext'; const Header = () => { - const { setSettingsModalVisible } = useModalContext(); - - const openSettings = () => setSettingsModalVisible(true); - return ( @@ -16,9 +11,7 @@ const Header = () => { - - - + diff --git a/frontend/src/components/WorkspaceMenu.js b/frontend/src/components/WorkspaceMenu.js new file mode 100644 index 0000000..5beff44 --- /dev/null +++ b/frontend/src/components/WorkspaceMenu.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { Menu, ActionIcon, Text } from '@mantine/core'; +import { + IconFolders, + IconFolderPlus, + IconSwitchHorizontal, + IconSettings, + IconTrash, + IconPencil, + IconFileExport, + IconFileImport, +} from '@tabler/icons-react'; +import { useModalContext } from '../contexts/ModalContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; + +const WorkspaceMenu = () => { + const { setSettingsModalVisible } = useModalContext(); + const { currentWorkspace } = useWorkspace(); + + const openSettings = () => setSettingsModalVisible(true); + + return ( + + + + + + + + + Current Workspace + + + {currentWorkspace?.name || 'No workspace selected'} + + + + + + Workspace Actions + }> + Create Workspace + + }> + Switch Workspace + + }> + Rename Workspace + + } color="red"> + Delete Workspace + + + + + Data Management + }> + Export Workspace + + }> + Import Workspace + + + + + } + onClick={openSettings} + > + Workspace Settings + + + + ); +}; + +export default WorkspaceMenu; From 12312137b737bec69cae4fbc3e31c32b443f7a0d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 26 Oct 2024 23:15:23 +0200 Subject: [PATCH 23/32] Implement workspace switching --- frontend/src/components/Header.js | 4 +- frontend/src/components/WorkspaceMenu.js | 77 ----------- frontend/src/components/WorkspaceSwitcher.js | 136 +++++++++++++++++++ frontend/src/contexts/ModalContext.js | 4 + frontend/src/contexts/WorkspaceContext.js | 53 ++++++-- frontend/src/hooks/useFileList.js | 2 +- frontend/src/hooks/useFileNavigation.js | 2 +- frontend/src/hooks/useFileOperations.js | 2 +- 8 files changed, 189 insertions(+), 91 deletions(-) delete mode 100644 frontend/src/components/WorkspaceMenu.js create mode 100644 frontend/src/components/WorkspaceSwitcher.js diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 0fbe38d..b9f894a 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,6 +1,6 @@ import React from 'react'; import { Group, Text, Avatar } from '@mantine/core'; -import WorkspaceMenu from './WorkspaceMenu'; +import WorkspaceSwitcher from './WorkspaceSwitcher'; import Settings from './Settings'; const Header = () => { @@ -10,8 +10,8 @@ const Header = () => { NovaMD + - diff --git a/frontend/src/components/WorkspaceMenu.js b/frontend/src/components/WorkspaceMenu.js deleted file mode 100644 index 5beff44..0000000 --- a/frontend/src/components/WorkspaceMenu.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { Menu, ActionIcon, Text } from '@mantine/core'; -import { - IconFolders, - IconFolderPlus, - IconSwitchHorizontal, - IconSettings, - IconTrash, - IconPencil, - IconFileExport, - IconFileImport, -} from '@tabler/icons-react'; -import { useModalContext } from '../contexts/ModalContext'; -import { useWorkspace } from '../contexts/WorkspaceContext'; - -const WorkspaceMenu = () => { - const { setSettingsModalVisible } = useModalContext(); - const { currentWorkspace } = useWorkspace(); - - const openSettings = () => setSettingsModalVisible(true); - - return ( - - - - - - - - - Current Workspace - - - {currentWorkspace?.name || 'No workspace selected'} - - - - - - Workspace Actions - }> - Create Workspace - - }> - Switch Workspace - - }> - Rename Workspace - - } color="red"> - Delete Workspace - - - - - Data Management - }> - Export Workspace - - }> - Import Workspace - - - - - } - onClick={openSettings} - > - Workspace Settings - - - - ); -}; - -export default WorkspaceMenu; diff --git a/frontend/src/components/WorkspaceSwitcher.js b/frontend/src/components/WorkspaceSwitcher.js new file mode 100644 index 0000000..196f27f --- /dev/null +++ b/frontend/src/components/WorkspaceSwitcher.js @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { + Box, + Popover, + Stack, + Paper, + ScrollArea, + Group, + UnstyledButton, + Text, + Loader, + Center, + Button, + ActionIcon, +} from '@mantine/core'; +import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; +import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useModalContext } from '../contexts/ModalContext'; +import { listWorkspaces } from '../services/api'; + +const WorkspaceSwitcher = () => { + const { currentWorkspace, switchWorkspace } = useWorkspace(); + const { setSettingsModalVisible } = useModalContext(); + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(false); + const [popoverOpened, setPopoverOpened] = useState(false); + + const loadWorkspaces = async () => { + setLoading(true); + try { + const list = await listWorkspaces(); + setWorkspaces(list); + } catch (error) { + console.error('Failed to load workspaces:', error); + } + setLoading(false); + }; + + return ( + + + { + setPopoverOpened((o) => !o); + if (!popoverOpened) { + loadWorkspaces(); + } + }} + > + + +
+ + {currentWorkspace?.name || 'No workspace'} + +
+
+
+
+ + + + Switch Workspace + + + + {loading ? ( +
+ +
+ ) : ( + workspaces.map((workspace) => ( + { + switchWorkspace(workspace.id); + setPopoverOpened(false); + }} + > + + + + + {workspace.name} + + + {new Date(workspace.createdAt).toLocaleDateString()} + + + {workspace.id === currentWorkspace?.id && ( + { + e.stopPropagation(); + setSettingsModalVisible(true); + setPopoverOpened(false); + }} + > + + + )} + + + + )) + )} +
+
+ +
+
+ ); +}; + +export default WorkspaceSwitcher; diff --git a/frontend/src/contexts/ModalContext.js b/frontend/src/contexts/ModalContext.js index 697bdc7..3338c7e 100644 --- a/frontend/src/contexts/ModalContext.js +++ b/frontend/src/contexts/ModalContext.js @@ -8,6 +8,8 @@ export const ModalProvider = ({ children }) => { const [commitMessageModalVisible, setCommitMessageModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false); + const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] = + useState(false); const value = { newFileModalVisible, @@ -18,6 +20,8 @@ export const ModalProvider = ({ children }) => { setCommitMessageModalVisible, settingsModalVisible, setSettingsModalVisible, + switchWorkspaceModalVisible, + setSwitchWorkspaceModalVisible, }; return ( diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index f228b0c..0006a77 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -6,6 +6,7 @@ import React, { useCallback, } from 'react'; import { useMantineColorScheme } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { fetchLastWorkspaceId, fetchWorkspaceSettings, @@ -22,18 +23,29 @@ export const WorkspaceProvider = ({ children }) => { const [loading, setLoading] = useState(true); const { colorScheme, setColorScheme } = useMantineColorScheme(); + const loadWorkspaceData = useCallback(async (workspaceId) => { + try { + const workspace = await getWorkspace(workspaceId); + setCurrentWorkspace(workspace); + const workspaceSettings = await fetchWorkspaceSettings(workspaceId); + setSettings(workspaceSettings.settings); + setColorScheme(workspaceSettings.settings.theme); + } catch (error) { + console.error('Failed to load workspace data:', error); + notifications.show({ + title: 'Error', + message: 'Failed to load workspace data', + color: 'red', + }); + } + }, []); + useEffect(() => { - const loadWorkspace = async () => { + const initializeWorkspace = async () => { try { const { lastWorkspaceId } = await fetchLastWorkspaceId(); if (lastWorkspaceId) { - const workspace = await getWorkspace(lastWorkspaceId); - setCurrentWorkspace(workspace); - const workspaceSettings = await fetchWorkspaceSettings( - lastWorkspaceId - ); - setSettings(workspaceSettings.settings); - setColorScheme(workspaceSettings.settings.theme); + await loadWorkspaceData(lastWorkspaceId); } else { console.warn('No last workspace found'); } @@ -44,7 +56,29 @@ export const WorkspaceProvider = ({ children }) => { } }; - loadWorkspace(); + initializeWorkspace(); + }, []); + + const switchWorkspace = useCallback(async (workspaceId) => { + try { + setLoading(true); + await updateLastWorkspace(workspaceId); + await loadWorkspaceData(workspaceId); + notifications.show({ + title: 'Success', + message: 'Workspace switched successfully', + color: 'green', + }); + } catch (error) { + console.error('Failed to switch workspace:', error); + notifications.show({ + title: 'Error', + message: 'Failed to switch workspace', + color: 'red', + }); + } finally { + setLoading(false); + } }, []); const updateSettings = useCallback( @@ -78,6 +112,7 @@ export const WorkspaceProvider = ({ children }) => { loading, colorScheme, updateColorScheme, + switchWorkspace, }; return ( diff --git a/frontend/src/hooks/useFileList.js b/frontend/src/hooks/useFileList.js index 46ca9ec..ef72c47 100644 --- a/frontend/src/hooks/useFileList.js +++ b/frontend/src/hooks/useFileList.js @@ -20,7 +20,7 @@ export const useFileList = () => { console.error('Failed to load file list:', error); setFiles([]); } - }, [currentWorkspace, workspaceLoading]); + }, [currentWorkspace]); return { files, loadFileList }; }; diff --git a/frontend/src/hooks/useFileNavigation.js b/frontend/src/hooks/useFileNavigation.js index 7c3623d..23ce637 100644 --- a/frontend/src/hooks/useFileNavigation.js +++ b/frontend/src/hooks/useFileNavigation.js @@ -38,7 +38,7 @@ export const useFileNavigation = () => { }); } }, - [currentWorkspace, handleFileSelect] + [currentWorkspace] ); return { handleLinkClick, selectedFile, isNewFile, handleFileSelect }; diff --git a/frontend/src/hooks/useFileOperations.js b/frontend/src/hooks/useFileOperations.js index 4b7f70a..0110755 100644 --- a/frontend/src/hooks/useFileOperations.js +++ b/frontend/src/hooks/useFileOperations.js @@ -21,7 +21,7 @@ export const useFileOperations = () => { await handleCommitAndPush(commitMessage); } }, - [settings, handleCommitAndPush] + [settings] ); const handleSave = useCallback( From bbd7358d1592afc07320f04d4dabb9d4f7eec7ad Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 26 Oct 2024 23:29:33 +0200 Subject: [PATCH 24/32] Add CreateWorkspaceModal --- frontend/src/components/WorkspaceSwitcher.js | 202 ++++++++++-------- .../components/modals/CreateWorkspaceModal.js | 82 +++++++ frontend/src/contexts/ModalContext.js | 4 + 3 files changed, 195 insertions(+), 93 deletions(-) create mode 100644 frontend/src/components/modals/CreateWorkspaceModal.js diff --git a/frontend/src/components/WorkspaceSwitcher.js b/frontend/src/components/WorkspaceSwitcher.js index 196f27f..078214b 100644 --- a/frontend/src/components/WorkspaceSwitcher.js +++ b/frontend/src/components/WorkspaceSwitcher.js @@ -17,10 +17,12 @@ import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { useWorkspace } from '../contexts/WorkspaceContext'; import { useModalContext } from '../contexts/ModalContext'; import { listWorkspaces } from '../services/api'; +import CreateWorkspaceModal from './modals/CreateWorkspaceModal'; const WorkspaceSwitcher = () => { const { currentWorkspace, switchWorkspace } = useWorkspace(); - const { setSettingsModalVisible } = useModalContext(); + const { setSettingsModalVisible, setCreateWorkspaceModalVisible } = + useModalContext(); const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(false); const [popoverOpened, setPopoverOpened] = useState(false); @@ -36,100 +38,114 @@ const WorkspaceSwitcher = () => { setLoading(false); }; - return ( - - - { - setPopoverOpened((o) => !o); - if (!popoverOpened) { - loadWorkspaces(); - } - }} - > - - -
- - {currentWorkspace?.name || 'No workspace'} - -
-
-
-
+ const handleCreateWorkspace = () => { + setPopoverOpened(false); + setCreateWorkspaceModalVisible(true); + }; - - - Switch Workspace - - - - {loading ? ( -
- -
- ) : ( - workspaces.map((workspace) => ( - { - switchWorkspace(workspace.id); - setPopoverOpened(false); - }} - > - { + await loadWorkspaces(); + switchWorkspace(newWorkspace.id); + }; + + return ( + <> + + + { + setPopoverOpened((o) => !o); + if (!popoverOpened) { + loadWorkspaces(); + } + }} + > + + +
+ + {currentWorkspace?.name || 'No workspace'} + +
+
+
+
+ + + + Switch Workspace + + + + {loading ? ( +
+ +
+ ) : ( + workspaces.map((workspace) => ( + { + switchWorkspace(workspace.id); + setPopoverOpened(false); + }} > - - - - {workspace.name} - - - {new Date(workspace.createdAt).toLocaleDateString()} - - - {workspace.id === currentWorkspace?.id && ( - { - e.stopPropagation(); - setSettingsModalVisible(true); - setPopoverOpened(false); - }} - > - - - )} - -
-
- )) - )} -
-
- -
-
+ + + + + {workspace.name} + + + {new Date(workspace.createdAt).toLocaleDateString()} + + + {workspace.id === currentWorkspace?.id && ( + { + e.stopPropagation(); + setSettingsModalVisible(true); + setPopoverOpened(false); + }} + > + + + )} + + + + )) + )} + + + + + + + ); }; diff --git a/frontend/src/components/modals/CreateWorkspaceModal.js b/frontend/src/components/modals/CreateWorkspaceModal.js new file mode 100644 index 0000000..265eccf --- /dev/null +++ b/frontend/src/components/modals/CreateWorkspaceModal.js @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; +import { useModalContext } from '../../contexts/ModalContext'; +import { createWorkspace } from '../../services/api'; +import { notifications } from '@mantine/notifications'; + +const CreateWorkspaceModal = ({ onWorkspaceCreated }) => { + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } = + useModalContext(); + + const handleSubmit = async () => { + if (!name.trim()) { + notifications.show({ + title: 'Error', + message: 'Workspace name is required', + color: 'red', + }); + return; + } + + setLoading(true); + try { + const workspace = await createWorkspace(name); + notifications.show({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + setName(''); + setCreateWorkspaceModalVisible(false); + if (onWorkspaceCreated) { + onWorkspaceCreated(workspace); + } + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to create workspace', + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + return ( + setCreateWorkspaceModalVisible(false)} + title="Create New Workspace" + centered + size="sm" + > + + setName(event.currentTarget.value)} + mb="md" + w="100%" + disabled={loading} + /> + + + + + + + ); +}; + +export default CreateWorkspaceModal; diff --git a/frontend/src/contexts/ModalContext.js b/frontend/src/contexts/ModalContext.js index 3338c7e..4865e6a 100644 --- a/frontend/src/contexts/ModalContext.js +++ b/frontend/src/contexts/ModalContext.js @@ -10,6 +10,8 @@ export const ModalProvider = ({ children }) => { const [settingsModalVisible, setSettingsModalVisible] = useState(false); const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] = useState(false); + const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] = + useState(false); const value = { newFileModalVisible, @@ -22,6 +24,8 @@ export const ModalProvider = ({ children }) => { setSettingsModalVisible, switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible, + createWorkspaceModalVisible, + setCreateWorkspaceModalVisible, }; return ( From eaad78730e52c9e85877afe9027b72450c31cde3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 12:44:01 +0100 Subject: [PATCH 25/32] Save default settings with workspace creation --- backend/internal/api/workspace_handlers.go | 16 ++++++++++++++++ backend/internal/db/workspace.go | 2 +- frontend/src/contexts/WorkspaceContext.js | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go index 3677777..38e3125 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/api/workspace_handlers.go @@ -54,6 +54,22 @@ func CreateWorkspace(db *db.DB) http.HandlerFunc { return } + defaultSettings := &models.WorkspaceSettings{ + WorkspaceID: workspace.ID, + Settings: models.UserSettings{ + Theme: "light", + AutoSave: false, + GitEnabled: false, + GitAutoCommit: false, + GitCommitMsgTemplate: "${action} ${filename}", + }, + } + + if err := db.SaveWorkspaceSettings(defaultSettings); err != nil { + http.Error(w, "Failed to initialize workspace settings", http.StatusInternalServerError) + return + } + respondJSON(w, workspace) } } diff --git a/backend/internal/db/workspace.go b/backend/internal/db/workspace.go index 98cf07c..9d02cae 100644 --- a/backend/internal/db/workspace.go +++ b/backend/internal/db/workspace.go @@ -5,7 +5,7 @@ import ( ) func (db *DB) CreateWorkspace(workspace *models.Workspace) error { - result, err := db.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?, ?)", + result, err := db.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?)", workspace.UserID, workspace.Name) if err != nil { return err diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index 0006a77..a0f5383 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -12,6 +12,7 @@ import { fetchWorkspaceSettings, saveWorkspaceSettings, getWorkspace, + updateLastWorkspace, } from '../services/api'; import { DEFAULT_SETTINGS } from '../utils/constants'; @@ -61,8 +62,10 @@ export const WorkspaceProvider = ({ children }) => { const switchWorkspace = useCallback(async (workspaceId) => { try { + console.log(workspaceId); setLoading(true); await updateLastWorkspace(workspaceId); + console.log('Hello'); await loadWorkspaceData(workspaceId); notifications.show({ title: 'Success', From ab7b018f881a9ec2b64b116bc242252f5ead30b6 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 14:50:21 +0100 Subject: [PATCH 26/32] Update WorkspaceSwitcher ui --- frontend/src/components/WorkspaceSwitcher.js | 145 ++++++++++++------- 1 file changed, 95 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/WorkspaceSwitcher.js b/frontend/src/components/WorkspaceSwitcher.js index 078214b..e9b79a0 100644 --- a/frontend/src/components/WorkspaceSwitcher.js +++ b/frontend/src/components/WorkspaceSwitcher.js @@ -10,8 +10,9 @@ import { Text, Loader, Center, - Button, ActionIcon, + Tooltip, + useMantineTheme, } from '@mantine/core'; import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { useWorkspace } from '../contexts/WorkspaceContext'; @@ -26,6 +27,7 @@ const WorkspaceSwitcher = () => { const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(false); const [popoverOpened, setPopoverOpened] = useState(false); + const theme = useMantineTheme(); const loadWorkspaces = async () => { setLoading(true); @@ -77,71 +79,114 @@ const WorkspaceSwitcher = () => { - - - Switch Workspace - - + + + + Workspaces + + + + + + + + {loading ? (
) : ( - workspaces.map((workspace) => ( - { - switchWorkspace(workspace.id); - setPopoverOpened(false); - }} - > + workspaces.map((workspace) => { + const isSelected = workspace.id === currentWorkspace?.id; + return ( - - - {workspace.name} - - - {new Date(workspace.createdAt).toLocaleDateString()} - - - {workspace.id === currentWorkspace?.id && ( - { - e.stopPropagation(); - setSettingsModalVisible(true); - setPopoverOpened(false); - }} - > - - + { + switchWorkspace(workspace.id); + setPopoverOpened(false); + }} + > + + + {workspace.name} + + + {new Date( + workspace.createdAt + ).toLocaleDateString()} + + + + {isSelected && ( + + { + e.stopPropagation(); + setSettingsModalVisible(true); + setPopoverOpened(false); + }} + > + + + )} - - )) + ); + }) )}
-
From 4544af8f0f88ed84295ecaa561d994a4787c8752 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 16:00:06 +0100 Subject: [PATCH 27/32] Combine settings and workspaces tables --- backend/internal/api/routes.go | 8 +- backend/internal/api/workspace_handlers.go | 137 ++++++------------- backend/internal/db/migrations.go | 18 +-- backend/internal/db/settings.go | 43 ------ backend/internal/db/{user.go => users.go} | 62 +++++++-- backend/internal/db/workspace.go | 59 -------- backend/internal/db/workspaces.go | 151 +++++++++++++++++++++ backend/internal/models/settings.go | 46 ------- backend/internal/models/user.go | 4 + backend/internal/models/workspace.go | 21 +++ backend/internal/user/user.go | 38 +----- 11 files changed, 281 insertions(+), 306 deletions(-) delete mode 100644 backend/internal/db/settings.go rename backend/internal/db/{user.go => users.go} (63%) delete mode 100644 backend/internal/db/workspace.go create mode 100644 backend/internal/db/workspaces.go delete mode 100644 backend/internal/models/settings.go diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index fcf25cd..af0b1f0 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -22,7 +22,7 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { r.Route("/{workspaceId}", func(r chi.Router) { r.Get("/", GetWorkspace(db)) - r.Put("/", UpdateWorkspace(db)) + r.Put("/", UpdateWorkspace(db, fs)) r.Delete("/", DeleteWorkspace(db)) // File routes @@ -38,12 +38,6 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { }) - // Settings routes - r.Route("/settings", func(r chi.Router) { - r.Get("/", GetWorkspaceSettings(db)) - r.Put("/", UpdateWorkspaceSettings(db, fs)) - }) - // Git routes r.Route("/git", func(r chi.Router) { r.Post("/commit", StageCommitAndPush(fs)) diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go index 38e3125..0d1ad0f 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/api/workspace_handlers.go @@ -35,41 +35,18 @@ func CreateWorkspace(db *db.DB) http.HandlerFunc { return } - var requestBody struct { - Name string `json:"name"` - } - - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + var workspace models.Workspace + if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } - workspace := &models.Workspace{ - UserID: userID, - Name: requestBody.Name, - } - - if err := db.CreateWorkspace(workspace); err != nil { + workspace.UserID = userID + if err := db.CreateWorkspace(&workspace); err != nil { http.Error(w, "Failed to create workspace", http.StatusInternalServerError) return } - defaultSettings := &models.WorkspaceSettings{ - WorkspaceID: workspace.ID, - Settings: models.UserSettings{ - Theme: "light", - AutoSave: false, - GitEnabled: false, - GitAutoCommit: false, - GitCommitMsgTemplate: "${action} ${filename}", - }, - } - - if err := db.SaveWorkspaceSettings(defaultSettings); err != nil { - http.Error(w, "Failed to initialize workspace settings", http.StatusInternalServerError) - return - } - respondJSON(w, workspace) } } @@ -97,7 +74,7 @@ func GetWorkspace(db *db.DB) http.HandlerFunc { } } -func UpdateWorkspace(db *db.DB) http.HandlerFunc { +func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, workspaceID, err := getUserAndWorkspaceIDs(r) if err != nil { @@ -105,33 +82,56 @@ func UpdateWorkspace(db *db.DB) http.HandlerFunc { return } - var requestBody struct { - Name string `json:"name"` - } - - if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + var workspace models.Workspace + if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } - workspace, err := db.GetWorkspaceByID(workspaceID) + // Set IDs from the request + workspace.ID = workspaceID + workspace.UserID = userID + + // Validate the workspace + if err := workspace.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get current workspace for comparison + currentWorkspace, err := db.GetWorkspaceByID(workspaceID) if err != nil { http.Error(w, "Workspace not found", http.StatusNotFound) return } - if workspace.UserID != userID { + if currentWorkspace.UserID != userID { http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) return } - workspace.Name = requestBody.Name - if err := db.UpdateWorkspace(workspace); err != nil { + // Handle Git repository setup/teardown if Git settings changed + if workspace.GitEnabled != currentWorkspace.GitEnabled || + (workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL || + workspace.GitUser != currentWorkspace.GitUser || + workspace.GitToken != currentWorkspace.GitToken)) { + if workspace.GitEnabled { + err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken) + if err != nil { + http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + fs.DisableGitRepo(userID, workspaceID) + } + } + + if err := db.UpdateWorkspace(&workspace); err != nil { http.Error(w, "Failed to update workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"name": workspace.Name}) + respondJSON(w, workspace) } } @@ -207,64 +207,3 @@ func UpdateLastWorkspace(db *db.DB) http.HandlerFunc { respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) } } - -func GetWorkspaceSettings(db *db.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - settings, err := db.GetWorkspaceSettings(workspaceID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - respondJSON(w, settings) - } -} - -func UpdateWorkspaceSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - var userSettings models.UserSettings - if err := json.NewDecoder(r.Body).Decode(&userSettings); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if err := userSettings.Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - workspaceSettings := &models.WorkspaceSettings{ - WorkspaceID: workspaceID, - Settings: userSettings, - } - - if err := db.SaveWorkspaceSettings(workspaceSettings); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if userSettings.GitEnabled { - err := fs.SetupGitRepo(userID, workspaceID, userSettings.GitURL, userSettings.GitUser, userSettings.GitToken) - if err != nil { - http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) - return - } - } else { - fs.DisableGitRepo(userID, workspaceID) - } - - respondJSON(w, workspaceSettings) - } -} diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index c6d234f..4eb9402 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -26,21 +26,23 @@ var migrations = []Migration{ last_opened_file_path TEXT ); - -- Create workspaces table + -- Create workspaces table with integrated settings CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Settings fields + theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), + auto_save BOOLEAN NOT NULL DEFAULT 0, + git_enabled BOOLEAN NOT NULL DEFAULT 0, + git_url TEXT, + git_user TEXT, + git_token TEXT, + git_auto_commit BOOLEAN NOT NULL DEFAULT 0, + git_commit_msg_template TEXT DEFAULT '${action} ${filename}', FOREIGN KEY (user_id) REFERENCES users (id) ); - - -- Create workspace_settings table - CREATE TABLE IF NOT EXISTS workspace_settings ( - workspace_id INTEGER PRIMARY KEY, - settings JSON NOT NULL, - FOREIGN KEY (workspace_id) REFERENCES workspaces (id) - ); `, }, } diff --git a/backend/internal/db/settings.go b/backend/internal/db/settings.go deleted file mode 100644 index 00820b6..0000000 --- a/backend/internal/db/settings.go +++ /dev/null @@ -1,43 +0,0 @@ -package db - -import ( - "database/sql" - "encoding/json" - - "novamd/internal/models" -) - -func (db *DB) GetWorkspaceSettings(workspaceID int) (*models.WorkspaceSettings, error) { - var settings models.WorkspaceSettings - var settingsJSON []byte - - err := db.QueryRow("SELECT workspace_id, settings FROM workspace_settings WHERE workspace_id = ?", workspaceID). - Scan(&settings.WorkspaceID, &settingsJSON) - if err != nil { - if err == sql.ErrNoRows { - // If no settings found, return default settings - settings.WorkspaceID = workspaceID - settings.Settings = models.UserSettings{} // This will be filled with defaults later - return &settings, nil - } - return nil, err - } - - err = json.Unmarshal(settingsJSON, &settings.Settings) - if err != nil { - return nil, err - } - - return &settings, nil -} - -func (db *DB) SaveWorkspaceSettings(settings *models.WorkspaceSettings) error { - settingsJSON, err := json.Marshal(settings.Settings) - if err != nil { - return err - } - - _, err = db.Exec("INSERT OR REPLACE INTO workspace_settings (workspace_id, settings) VALUES (?, ?)", - settings.WorkspaceID, settingsJSON) - return err -} \ No newline at end of file diff --git a/backend/internal/db/user.go b/backend/internal/db/users.go similarity index 63% rename from backend/internal/db/user.go rename to backend/internal/db/users.go index 1e388dc..4a0251d 100644 --- a/backend/internal/db/user.go +++ b/backend/internal/db/users.go @@ -26,11 +26,14 @@ func (db *DB) CreateUser(user *models.User) error { } user.ID = int(userID) - // Create default workspace + // Create default workspace with default settings defaultWorkspace := &models.Workspace{ UserID: user.ID, Name: "Main", } + defaultWorkspace.GetDefaultSettings() // Initialize default settings + + // Create workspace with settings err = db.createWorkspaceTx(tx, defaultWorkspace) if err != nil { return err @@ -52,8 +55,18 @@ func (db *DB) CreateUser(user *models.User) error { } func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { - result, err := tx.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?)", - workspace.UserID, workspace.Name) + result, err := tx.Exec(` + INSERT INTO workspaces ( + user_id, name, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspace.UserID, workspace.Name, + workspace.Theme, workspace.AutoSave, + workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken, + workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, + ) if err != nil { return err } @@ -68,10 +81,15 @@ func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { func (db *DB) GetUserByID(id int) (*models.User, error) { user := &models.User{} err := db.QueryRow(` - SELECT id, email, display_name, role, created_at, last_workspace_id, last_opened_file_path - FROM users WHERE id = ?`, id). + SELECT + u.id, u.email, u.display_name, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.id = ?`, id). Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt, - &user.LastWorkspaceID, &user.LastOpenedFilePath) + &user.LastWorkspaceID, &user.LastOpenedFilePath, &user.LastWorkspaceID) if err != nil { return nil, err } @@ -82,10 +100,15 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) { user := &models.User{} var lastOpenedFilePath sql.NullString err := db.QueryRow(` - SELECT id, email, display_name, password_hash, role, created_at, last_workspace_id, last_opened_file_path - FROM users WHERE email = ?`, email). + SELECT + u.id, u.email, u.display_name, u.password_hash, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.email = ?`, email). Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, - &user.LastWorkspaceID, &lastOpenedFilePath) + &user.LastWorkspaceID, &lastOpenedFilePath, &user.LastWorkspaceID) if err != nil { return nil, err } @@ -117,8 +140,25 @@ func (db *DB) UpdateLastOpenedFile(userID int, filePath string) error { } func (db *DB) DeleteUser(id int) error { - _, err := db.Exec("DELETE FROM users WHERE id = ?", id) - return err + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete all user's workspaces first + _, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id) + if err != nil { + return err + } + + // Delete the user + _, err = tx.Exec("DELETE FROM users WHERE id = ?", id) + if err != nil { + return err + } + + return tx.Commit() } func (db *DB) GetLastWorkspaceID(userID int) (int, error) { diff --git a/backend/internal/db/workspace.go b/backend/internal/db/workspace.go deleted file mode 100644 index 9d02cae..0000000 --- a/backend/internal/db/workspace.go +++ /dev/null @@ -1,59 +0,0 @@ -package db - -import ( - "novamd/internal/models" -) - -func (db *DB) CreateWorkspace(workspace *models.Workspace) error { - result, err := db.Exec("INSERT INTO workspaces (user_id, name) VALUES (?, ?)", - workspace.UserID, workspace.Name) - if err != nil { - return err - } - id, err := result.LastInsertId() - if err != nil { - return err - } - workspace.ID = int(id) - return nil -} - -func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { - workspace := &models.Workspace{} - err := db.QueryRow("SELECT id, user_id, name, created_at FROM workspaces WHERE id = ?", id). - Scan(&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt) - if err != nil { - return nil, err - } - return workspace, nil -} - -func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { - rows, err := db.Query("SELECT id, user_id, name, created_at FROM workspaces WHERE user_id = ?", userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var workspaces []*models.Workspace - for rows.Next() { - workspace := &models.Workspace{} - err := rows.Scan(&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt) - if err != nil { - return nil, err - } - workspaces = append(workspaces, workspace) - } - return workspaces, nil -} - -func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { - _, err := db.Exec("UPDATE workspaces SET name = ? WHERE id = ? AND user_id = ?", - workspace.Name, workspace.ID, workspace.UserID) - return err -} - -func (db *DB) DeleteWorkspace(id int) error { - _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) - return err -} diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go new file mode 100644 index 0000000..7fb2038 --- /dev/null +++ b/backend/internal/db/workspaces.go @@ -0,0 +1,151 @@ +package db + +import ( + "novamd/internal/models" +) + +func (db *DB) CreateWorkspace(workspace *models.Workspace) error { + // Set default settings if not provided + if workspace.Theme == "" { + workspace.GetDefaultSettings() + } + + result, err := db.Exec(` + INSERT INTO workspaces ( + user_id, name, theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave, + workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken, + workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, + ) + if err != nil { + return err + } + + id, err := result.LastInsertId() + if err != nil { + return err + } + workspace.ID = int(id) + return nil +} + +func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { + workspace := &models.Workspace{} + err := db.QueryRow(` + SELECT + id, user_id, name, created_at, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + FROM workspaces + WHERE id = ?`, + id, + ).Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + return workspace, nil +} + +func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { + rows, err := db.Query(` + SELECT + id, user_id, name, created_at, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + FROM workspaces + WHERE user_id = ?`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var workspaces []*models.Workspace + for rows.Next() { + workspace := &models.Workspace{} + err := rows.Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} + +func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { + _, err := db.Exec(` + UPDATE workspaces + SET + name = ?, + theme = ?, + auto_save = ?, + git_enabled = ?, + git_url = ?, + git_user = ?, + git_token = ?, + git_auto_commit = ?, + git_commit_msg_template = ? + WHERE id = ? AND user_id = ?`, + workspace.Name, + workspace.Theme, + workspace.AutoSave, + workspace.GitEnabled, + workspace.GitURL, + workspace.GitUser, + workspace.GitToken, + workspace.GitAutoCommit, + workspace.GitCommitMsgTemplate, + workspace.ID, + workspace.UserID, + ) + return err +} + +// UpdateWorkspaceSettings updates only the settings portion of a workspace +// This is useful when you don't want to modify the name or other core workspace properties +func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error { + _, err := db.Exec(` + UPDATE workspaces + SET + theme = ?, + auto_save = ?, + git_enabled = ?, + git_url = ?, + git_user = ?, + git_token = ?, + git_auto_commit = ?, + git_commit_msg_template = ? + WHERE id = ?`, + workspace.Theme, + workspace.AutoSave, + workspace.GitEnabled, + workspace.GitURL, + workspace.GitUser, + workspace.GitToken, + workspace.GitAutoCommit, + workspace.GitCommitMsgTemplate, + workspace.ID, + ) + return err +} + +func (db *DB) DeleteWorkspace(id int) error { + _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) + return err +} diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go deleted file mode 100644 index 52a15cb..0000000 --- a/backend/internal/models/settings.go +++ /dev/null @@ -1,46 +0,0 @@ -package models - -import ( - "encoding/json" - - "github.com/go-playground/validator/v10" -) - -type UserSettings struct { - Theme string `json:"theme" validate:"oneof=light dark"` - AutoSave bool `json:"autoSave"` - GitEnabled bool `json:"gitEnabled"` - GitURL string `json:"gitUrl" validate:"required_with=GitEnabled"` - GitUser string `json:"gitUser" validate:"required_with=GitEnabled"` - GitToken string `json:"gitToken" validate:"required_with=GitEnabled"` - GitAutoCommit bool `json:"gitAutoCommit"` - GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` -} - -type WorkspaceSettings struct { - WorkspaceID int `json:"workspaceId" validate:"required,min=1"` - Settings UserSettings `json:"settings" validate:"required"` -} - -var validate = validator.New() - -func (s *UserSettings) Validate() error { - return validate.Struct(s) -} - -func (ws *WorkspaceSettings) Validate() error { - return validate.Struct(ws) -} - -func (ws *WorkspaceSettings) UnmarshalJSON(data []byte) error { - type Alias WorkspaceSettings - aux := &struct { - *Alias - }{ - Alias: (*Alias)(ws), - } - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - return ws.Validate() -} \ No newline at end of file diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index ff8e599..8881601 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -2,8 +2,12 @@ package models import ( "time" + + "github.com/go-playground/validator/v10" ) +var validate = validator.New() + type UserRole string const ( diff --git a/backend/internal/models/workspace.go b/backend/internal/models/workspace.go index ced4816..28f1bc5 100644 --- a/backend/internal/models/workspace.go +++ b/backend/internal/models/workspace.go @@ -9,8 +9,29 @@ type Workspace struct { UserID int `json:"userId" validate:"required,min=1"` Name string `json:"name" validate:"required"` CreatedAt time.Time `json:"createdAt"` + + // Integrated settings + Theme string `json:"theme" validate:"oneof=light dark"` + AutoSave bool `json:"autoSave"` + GitEnabled bool `json:"gitEnabled"` + GitURL string `json:"gitUrl" validate:"required_if=GitEnabled true"` + GitUser string `json:"gitUser" validate:"required_if=GitEnabled true"` + GitToken string `json:"gitToken" validate:"required_if=GitEnabled true"` + GitAutoCommit bool `json:"gitAutoCommit"` + GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` } func (w *Workspace) Validate() error { return validate.Struct(w) } + +func (w *Workspace) GetDefaultSettings() { + w.Theme = "light" + w.AutoSave = false + w.GitEnabled = false + w.GitURL = "" + w.GitUser = "" + w.GitToken = "" + w.GitAutoCommit = false + w.GitCommitMsgTemplate = "${action} ${filename}" +} diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go index ed7d8e5..b725256 100644 --- a/backend/internal/user/user.go +++ b/backend/internal/user/user.go @@ -17,17 +17,6 @@ type UserService struct { FS *filesystem.FileSystem } -// Default settings for new workspaces -var defaultWorkspaceSettings = models.WorkspaceSettings{ - Settings: models.UserSettings{ - Theme: "light", - AutoSave: false, - GitEnabled: false, - GitAutoCommit: false, - GitCommitMsgTemplate: "${action} ${filename}", - }, -} - func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService { return &UserService{ DB: database, @@ -68,18 +57,12 @@ func (s *UserService) SetupAdminUser() (*models.User, error) { return nil, fmt.Errorf("failed to create admin user: %w", err) } + // Initialize workspace directory err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID) if err != nil { return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) } - // Save default settings for the admin workspace - defaultWorkspaceSettings.WorkspaceID = adminUser.LastWorkspaceID - err = s.DB.SaveWorkspaceSettings(&defaultWorkspaceSettings) - if err != nil { - return nil, fmt.Errorf("failed to save default workspace settings: %w", err) - } - log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID) return adminUser, nil @@ -96,14 +79,6 @@ func (s *UserService) CreateUser(user *models.User) error { return fmt.Errorf("failed to initialize user workspace: %w", err) } - // Save default settings for the user's workspace - settings := defaultWorkspaceSettings - settings.WorkspaceID = user.LastWorkspaceID - err = s.DB.SaveWorkspaceSettings(&settings) - if err != nil { - return fmt.Errorf("failed to save default workspace settings: %w", err) - } - return nil } @@ -120,29 +95,26 @@ func (s *UserService) UpdateUser(user *models.User) error { } func (s *UserService) DeleteUser(id int) error { - // First, get the user to check if they exist and to get their workspaces + // First, get the user to check if they exist user, err := s.DB.GetUserByID(id) if err != nil { return fmt.Errorf("failed to get user: %w", err) } - // Delete user's workspaces + // Get user's workspaces workspaces, err := s.DB.GetWorkspacesByUserID(id) if err != nil { return fmt.Errorf("failed to get user's workspaces: %w", err) } + // Delete workspace directories for _, workspace := range workspaces { - err = s.DB.DeleteWorkspace(workspace.ID) - if err != nil { - return fmt.Errorf("failed to delete workspace: %w", err) - } err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID) if err != nil { return fmt.Errorf("failed to delete workspace files: %w", err) } } - // Finally, delete the user + // Delete user from database (this will cascade delete workspaces) return s.DB.DeleteUser(id) } From 17c03c2d146aff7feb754c630a48f3859cb1adfe Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 16:53:15 +0100 Subject: [PATCH 28/32] Update Workspace Settings on frontend --- frontend/src/components/Settings.js | 14 ++++++-- frontend/src/contexts/WorkspaceContext.js | 39 +++++++++++------------ frontend/src/services/api.js | 28 +++------------- frontend/src/utils/constants.js | 9 +++++- 4 files changed, 43 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index d810cd1..21a32ac 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -44,7 +44,7 @@ function settingsReducer(state, action) { } const Settings = () => { - const { settings, updateSettings } = useWorkspace(); + const { currentWorkspace, updateSettings } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const [state, dispatch] = useReducer(settingsReducer, initialState); const isInitialMount = useRef(true); @@ -52,9 +52,19 @@ const Settings = () => { useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; + const settings = { + theme: currentWorkspace.theme, + autoSave: currentWorkspace.autoSave, + gitEnabled: currentWorkspace.gitEnabled, + gitUrl: currentWorkspace.gitUrl, + gitUser: currentWorkspace.gitUser, + gitToken: currentWorkspace.gitToken, + gitAutoCommit: currentWorkspace.gitAutoCommit, + gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate, + }; dispatch({ type: 'INIT_SETTINGS', payload: settings }); } - }, [settings]); + }, [currentWorkspace]); const handleInputChange = useCallback((key, value) => { dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index a0f5383..8110ce8 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -9,18 +9,16 @@ import { useMantineColorScheme } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { fetchLastWorkspaceId, - fetchWorkspaceSettings, - saveWorkspaceSettings, getWorkspace, + updateWorkspace, updateLastWorkspace, } from '../services/api'; -import { DEFAULT_SETTINGS } from '../utils/constants'; +import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants'; const WorkspaceContext = createContext(); export const WorkspaceProvider = ({ children }) => { const [currentWorkspace, setCurrentWorkspace] = useState(null); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [loading, setLoading] = useState(true); const { colorScheme, setColorScheme } = useMantineColorScheme(); @@ -28,9 +26,7 @@ export const WorkspaceProvider = ({ children }) => { try { const workspace = await getWorkspace(workspaceId); setCurrentWorkspace(workspace); - const workspaceSettings = await fetchWorkspaceSettings(workspaceId); - setSettings(workspaceSettings.settings); - setColorScheme(workspaceSettings.settings.theme); + setColorScheme(workspace.theme); } catch (error) { console.error('Failed to load workspace data:', error); notifications.show({ @@ -62,10 +58,8 @@ export const WorkspaceProvider = ({ children }) => { const switchWorkspace = useCallback(async (workspaceId) => { try { - console.log(workspaceId); setLoading(true); await updateLastWorkspace(workspaceId); - console.log('Hello'); await loadWorkspaceData(workspaceId); notifications.show({ title: 'Success', @@ -89,28 +83,33 @@ export const WorkspaceProvider = ({ children }) => { if (!currentWorkspace) return; try { - await saveWorkspaceSettings(currentWorkspace.id, newSettings); - setSettings(newSettings); - setColorScheme(newSettings.theme); + const updatedWorkspace = { + ...currentWorkspace, + ...newSettings, + }; + + const response = await updateWorkspace( + currentWorkspace.id, + updatedWorkspace + ); + setCurrentWorkspace(response); + setColorScheme(response.theme); } catch (error) { console.error('Failed to save settings:', error); throw error; } }, - [currentWorkspace, setColorScheme] + [currentWorkspace] ); // Update just the color scheme without saving to backend - const updateColorScheme = useCallback( - (newTheme) => { - setColorScheme(newTheme); - }, - [setColorScheme] - ); + const updateColorScheme = useCallback((newTheme) => { + setColorScheme(newTheme); + }, []); const value = { currentWorkspace, - settings, + settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS, updateSettings, loading, colorScheme, diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 58ab827..6fbed6c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -66,22 +66,16 @@ export const getWorkspace = async (workspaceId) => { return response.json(); }; -export const fetchWorkspaceSettings = async (workspaceId) => { +// Combined function to update workspace data including settings +export const updateWorkspace = async (workspaceId, workspaceData) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/settings` - ); - return response.json(); -}; - -export const saveWorkspaceSettings = async (workspaceId, settings) => { - const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/settings`, + `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(settings), + body: JSON.stringify(workspaceData), } ); return response.json(); @@ -162,20 +156,6 @@ export const createWorkspace = async (name) => { return response.json(); }; -export const updateWorkspace = async (workspaceId, name) => { - const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - } - ); - return response.json(); -}; - export const deleteWorkspace = async (workspaceId) => { const response = await apiCall( `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index c4971b0..4ce4101 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [ '.svg', ]; -export const DEFAULT_SETTINGS = { +// Renamed from DEFAULT_SETTINGS to be more specific +export const DEFAULT_WORKSPACE_SETTINGS = { theme: THEMES.LIGHT, autoSave: false, gitEnabled: false, @@ -37,6 +38,12 @@ export const DEFAULT_SETTINGS = { gitCommitMsgTemplate: '${action} ${filename}', }; +// Template for creating new workspaces +export const DEFAULT_WORKSPACE = { + name: '', + ...DEFAULT_WORKSPACE_SETTINGS, +}; + export const DEFAULT_FILE = { name: 'New File.md', path: 'New File.md', From c5e0937070912660dc59ee46091a74f9caf2aa1c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 17:39:56 +0100 Subject: [PATCH 29/32] Add rename and delete workspace ui elements --- frontend/src/components/Settings.js | 81 ++++++++++++------- .../components/modals/DeleteWorkspaceModal.js | 35 ++++++++ .../components/settings/DangerZoneSettings.js | 40 +++++++++ .../components/settings/GeneralSettings.js | 32 ++++++++ frontend/src/contexts/WorkspaceContext.js | 5 -- 5 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/modals/DeleteWorkspaceModal.js create mode 100644 frontend/src/components/settings/DangerZoneSettings.js create mode 100644 frontend/src/components/settings/GeneralSettings.js diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index 21a32ac..18cba57 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -1,11 +1,21 @@ import React, { useReducer, useEffect, useCallback, useRef } from 'react'; -import { Modal, Badge, Button, Group, Title } from '@mantine/core'; +import { + Modal, + Badge, + Button, + Group, + Title, + Stack, + Divider, +} from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useWorkspace } from '../contexts/WorkspaceContext'; import AppearanceSettings from './settings/AppearanceSettings'; import EditorSettings from './settings/EditorSettings'; import GitSettings from './settings/GitSettings'; +import GeneralSettings from './settings/GeneralSettings'; import { useModalContext } from '../contexts/ModalContext'; +import DangerZoneSettings from './settings/DangerZoneSettings'; const initialState = { localSettings: {}, @@ -100,34 +110,47 @@ const Settings = () => { centered size="lg" > - {state.hasUnsavedChanges && ( - - Unsaved Changes - - )} - handleInputChange('theme', newTheme)} - /> - handleInputChange('autoSave', value)} - /> - - - - - + + {state.hasUnsavedChanges && ( + + Unsaved Changes + + )} + + + + + handleInputChange('theme', newTheme)} + /> + + + handleInputChange('autoSave', value)} + /> + + + + + + + + + + +
); }; diff --git a/frontend/src/components/modals/DeleteWorkspaceModal.js b/frontend/src/components/modals/DeleteWorkspaceModal.js new file mode 100644 index 0000000..8effeeb --- /dev/null +++ b/frontend/src/components/modals/DeleteWorkspaceModal.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { Modal, Text, Button, Group, Stack } from '@mantine/core'; + +const DeleteWorkspaceModal = ({ + opened, + onClose, + onConfirm, + workspaceName, +}) => ( + + + + Are you sure you want to delete workspace "{workspaceName}"? This action + cannot be undone and all files in this workspace will be permanently + deleted. + + + + + + + +); + +export default DeleteWorkspaceModal; diff --git a/frontend/src/components/settings/DangerZoneSettings.js b/frontend/src/components/settings/DangerZoneSettings.js new file mode 100644 index 0000000..8c328e9 --- /dev/null +++ b/frontend/src/components/settings/DangerZoneSettings.js @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { Box, Button, Title } from '@mantine/core'; +import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal'; +import { useWorkspace } from '../../contexts/WorkspaceContext'; + +const DangerZoneSettings = () => { + const { currentWorkspace } = useWorkspace(); + const [deleteModalOpened, setDeleteModalOpened] = useState(false); + + const handleDelete = () => { + // TODO: Implement delete functionality + setDeleteModalOpened(false); + }; + + return ( + + + Danger Zone + + + + + setDeleteModalOpened(false)} + onConfirm={handleDelete} + workspaceName={currentWorkspace?.name} + /> + + ); +}; + +export default DangerZoneSettings; diff --git a/frontend/src/components/settings/GeneralSettings.js b/frontend/src/components/settings/GeneralSettings.js new file mode 100644 index 0000000..e1f916e --- /dev/null +++ b/frontend/src/components/settings/GeneralSettings.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { Title, Box, TextInput, Text, Grid } from '@mantine/core'; +import { useWorkspace } from '../../contexts/WorkspaceContext'; + +const GeneralSettings = ({ onInputChange }) => { + const { currentWorkspace } = useWorkspace(); + + return ( + + + General + + + + + Workspace Name + + + + onInputChange('name', event.currentTarget.value) + } + placeholder="Enter workspace name" + /> + + + + ); +}; + +export default GeneralSettings; diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index 8110ce8..2bae145 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -61,11 +61,6 @@ export const WorkspaceProvider = ({ children }) => { setLoading(true); await updateLastWorkspace(workspaceId); await loadWorkspaceData(workspaceId); - notifications.show({ - title: 'Success', - message: 'Workspace switched successfully', - color: 'green', - }); } catch (error) { console.error('Failed to switch workspace:', error); notifications.show({ From b679af08e7911017ea991316e9d4e449d33d4d35 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 27 Oct 2024 18:15:11 +0100 Subject: [PATCH 30/32] Enable workspace renaming --- frontend/src/components/Settings.js | 14 +++++++++++++- .../src/components/settings/GeneralSettings.js | 8 +++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index 18cba57..b64f51c 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -63,6 +63,7 @@ const Settings = () => { if (isInitialMount.current) { isInitialMount.current = false; const settings = { + name: currentWorkspace.name, theme: currentWorkspace.theme, autoSave: currentWorkspace.autoSave, gitEnabled: currentWorkspace.gitEnabled, @@ -82,6 +83,14 @@ const Settings = () => { const handleSubmit = async () => { try { + if (!state.localSettings.name?.trim()) { + notifications.show({ + message: 'Workspace name cannot be empty', + color: 'red', + }); + return; + } + await updateSettings(state.localSettings); dispatch({ type: 'MARK_SAVED' }); notifications.show({ @@ -117,7 +126,10 @@ const Settings = () => { )} - + { - const { currentWorkspace } = useWorkspace(); +const GeneralSettings = ({ name, onInputChange }) => { return ( @@ -17,11 +14,12 @@ const GeneralSettings = ({ onInputChange }) => { </Grid.Col> <Grid.Col span={6}> <TextInput - value={currentWorkspace?.name || ''} + value={name || ''} onChange={(event) => onInputChange('name', event.currentTarget.value) } placeholder="Enter workspace name" + required /> </Grid.Col> </Grid> From ba4a0dadca4079a8c3ec7c8de663422ff583a567 Mon Sep 17 00:00:00 2001 From: LordMathis <matus@namesny.com> Date: Sun, 27 Oct 2024 21:19:42 +0100 Subject: [PATCH 31/32] Implement workspace deletion --- backend/internal/api/workspace_handlers.go | 47 +++++++-- backend/internal/db/workspaces.go | 11 +++ .../components/settings/DangerZoneSettings.js | 16 +++- frontend/src/contexts/WorkspaceContext.js | 95 +++++++++++++++++-- 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go index 0d1ad0f..c1c23c6 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/api/workspace_handlers.go @@ -143,24 +143,57 @@ func DeleteWorkspace(db *db.DB) http.HandlerFunc { return } - workspace, err := db.GetWorkspaceByID(workspaceID) + // Check if this is the user's last workspace + workspaces, err := db.GetWorkspacesByUserID(userID) if err != nil { - http.Error(w, "Workspace not found", http.StatusNotFound) + http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) return } - if workspace.UserID != userID { - http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + if len(workspaces) <= 1 { + http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest) return } - if err := db.DeleteWorkspace(workspaceID); err != nil { + // Find another workspace to set as last + var nextWorkspaceID int + for _, ws := range workspaces { + if ws.ID != workspaceID { + nextWorkspaceID = ws.ID + break + } + } + + // Start transaction + tx, err := db.Begin() + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Update last workspace ID first + err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID) + if err != nil { + http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + return + } + + // Delete the workspace + err = db.DeleteWorkspaceTx(tx, workspaceID) + if err != nil { http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) - w.Write([]byte("Workspace deleted successfully")) + // Commit transaction + if err = tx.Commit(); err != nil { + http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) + return + } + + // Return the next workspace ID in the response so frontend knows where to redirect + respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID}) } } diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go index 7fb2038..344e4e2 100644 --- a/backend/internal/db/workspaces.go +++ b/backend/internal/db/workspaces.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "novamd/internal/models" ) @@ -149,3 +150,13 @@ func (db *DB) DeleteWorkspace(id int) error { _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) return err } + +func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error { + _, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id) + return err +} + +func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error { + _, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) + return err +} diff --git a/frontend/src/components/settings/DangerZoneSettings.js b/frontend/src/components/settings/DangerZoneSettings.js index 8c328e9..2f23517 100644 --- a/frontend/src/components/settings/DangerZoneSettings.js +++ b/frontend/src/components/settings/DangerZoneSettings.js @@ -2,14 +2,18 @@ import React, { useState } from 'react'; import { Box, Button, Title } from '@mantine/core'; import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal'; import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { useModalContext } from '../../contexts/ModalContext'; const DangerZoneSettings = () => { - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, workspaces, deleteCurrentWorkspace } = + useWorkspace(); + const { setSettingsModalVisible } = useModalContext(); const [deleteModalOpened, setDeleteModalOpened] = useState(false); - const handleDelete = () => { - // TODO: Implement delete functionality + const handleDelete = async () => { + await deleteCurrentWorkspace(); setDeleteModalOpened(false); + setSettingsModalVisible(false); }; return ( @@ -23,6 +27,12 @@ const DangerZoneSettings = () => { variant="light" onClick={() => setDeleteModalOpened(true)} fullWidth + disabled={workspaces.length <= 1} + title={ + workspaces.length <= 1 + ? 'Cannot delete the last workspace' + : 'Delete this workspace' + } > Delete Workspace </Button> diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js index 2bae145..f7804e2 100644 --- a/frontend/src/contexts/WorkspaceContext.js +++ b/frontend/src/contexts/WorkspaceContext.js @@ -12,6 +12,8 @@ import { getWorkspace, updateWorkspace, updateLastWorkspace, + deleteWorkspace, + listWorkspaces, } from '../services/api'; import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants'; @@ -19,9 +21,26 @@ const WorkspaceContext = createContext(); export const WorkspaceProvider = ({ children }) => { const [currentWorkspace, setCurrentWorkspace] = useState(null); + const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(true); const { colorScheme, setColorScheme } = useMantineColorScheme(); + const loadWorkspaces = useCallback(async () => { + try { + const workspaceList = await listWorkspaces(); + setWorkspaces(workspaceList); + return workspaceList; + } catch (error) { + console.error('Failed to load workspaces:', error); + notifications.show({ + title: 'Error', + message: 'Failed to load workspaces list', + color: 'red', + }); + return []; + } + }, []); + const loadWorkspaceData = useCallback(async (workspaceId) => { try { const workspace = await getWorkspace(workspaceId); @@ -37,6 +56,24 @@ export const WorkspaceProvider = ({ children }) => { } }, []); + const loadFirstAvailableWorkspace = useCallback(async () => { + try { + const allWorkspaces = await listWorkspaces(); + if (allWorkspaces.length > 0) { + const firstWorkspace = allWorkspaces[0]; + await updateLastWorkspace(firstWorkspace.id); + await loadWorkspaceData(firstWorkspace.id); + } + } catch (error) { + console.error('Failed to load first available workspace:', error); + notifications.show({ + title: 'Error', + message: 'Failed to load workspace', + color: 'red', + }); + } + }, []); + useEffect(() => { const initializeWorkspace = async () => { try { @@ -44,10 +81,12 @@ export const WorkspaceProvider = ({ children }) => { if (lastWorkspaceId) { await loadWorkspaceData(lastWorkspaceId); } else { - console.warn('No last workspace found'); + await loadFirstAvailableWorkspace(); } + await loadWorkspaces(); } catch (error) { console.error('Failed to initialize workspace:', error); + await loadFirstAvailableWorkspace(); } finally { setLoading(false); } @@ -61,6 +100,7 @@ export const WorkspaceProvider = ({ children }) => { setLoading(true); await updateLastWorkspace(workspaceId); await loadWorkspaceData(workspaceId); + await loadWorkspaces(); } catch (error) { console.error('Failed to switch workspace:', error); notifications.show({ @@ -73,6 +113,44 @@ export const WorkspaceProvider = ({ children }) => { } }, []); + const deleteCurrentWorkspace = useCallback(async () => { + if (!currentWorkspace) return; + + try { + const allWorkspaces = await loadWorkspaces(); + if (allWorkspaces.length <= 1) { + notifications.show({ + title: 'Error', + message: + 'Cannot delete the last workspace. At least one workspace must exist.', + color: 'red', + }); + return; + } + + // Delete workspace and get the next workspace ID + const response = await deleteWorkspace(currentWorkspace.id); + + // Load the new workspace data + await loadWorkspaceData(response.nextWorkspaceId); + + notifications.show({ + title: 'Success', + message: 'Workspace deleted successfully', + color: 'green', + }); + + await loadWorkspaces(); + } catch (error) { + console.error('Failed to delete workspace:', error); + notifications.show({ + title: 'Error', + message: 'Failed to delete workspace', + color: 'red', + }); + } + }, [currentWorkspace]); + const updateSettings = useCallback( async (newSettings) => { if (!currentWorkspace) return; @@ -89,27 +167,32 @@ export const WorkspaceProvider = ({ children }) => { ); setCurrentWorkspace(response); setColorScheme(response.theme); + await loadWorkspaces(); } catch (error) { console.error('Failed to save settings:', error); throw error; } }, - [currentWorkspace] + [currentWorkspace, setColorScheme] ); - // Update just the color scheme without saving to backend - const updateColorScheme = useCallback((newTheme) => { - setColorScheme(newTheme); - }, []); + const updateColorScheme = useCallback( + (newTheme) => { + setColorScheme(newTheme); + }, + [setColorScheme] + ); const value = { currentWorkspace, + workspaces, settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS, updateSettings, loading, colorScheme, updateColorScheme, switchWorkspace, + deleteCurrentWorkspace, }; return ( From 239b441aa65ddff104f16ed67fd5ca008b5c8f4a Mon Sep 17 00:00:00 2001 From: LordMathis <matus@namesny.com> Date: Sun, 27 Oct 2024 21:34:59 +0100 Subject: [PATCH 32/32] Improve Settings modal --- frontend/src/components/Settings.js | 113 ++++++++++++++---- .../components/settings/AppearanceSettings.js | 3 - .../components/settings/DangerZoneSettings.js | 4 - .../src/components/settings/EditorSettings.js | 3 - .../components/settings/GeneralSettings.js | 4 - .../src/components/settings/GitSettings.js | 1 - 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index b64f51c..a4ee4aa 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -6,7 +6,7 @@ import { Group, Title, Stack, - Divider, + Accordion, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useWorkspace } from '../contexts/WorkspaceContext'; @@ -53,6 +53,12 @@ function settingsReducer(state, action) { } } +const AccordionControl = ({ children }) => ( + <Accordion.Control> + <Title order={4}>{children} + +); + const Settings = () => { const { currentWorkspace, updateSettings } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); @@ -126,35 +132,90 @@ const Settings = () => { )} - - + ({ + control: { + paddingTop: theme.spacing.md, + paddingBottom: theme.spacing.md, + }, + item: { + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' + ? theme.colors.dark[4] + : theme.colors.gray[3] + }`, + '&[data-active]': { + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[7] + : theme.colors.gray[0], + }, + }, + chevron: { + '&[data-rotate]': { + transform: 'rotate(180deg)', + }, + }, + })} + > + + General + + + + - handleInputChange('theme', newTheme)} - /> - + + Appearance + + + handleInputChange('theme', newTheme) + } + /> + + - handleInputChange('autoSave', value)} - /> - + + Editor + + + handleInputChange('autoSave', value) + } + /> + + - + + Git Integration + + + + - + + Danger Zone + + + + +