Merge pull request #6 from LordMathis/feat/workspaces

Feat/workspaces
This commit is contained in:
2024-10-27 21:40:35 +01:00
committed by GitHub
45 changed files with 2273 additions and 770 deletions

View File

@@ -18,7 +18,8 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": "explicit" "source.organizeImports": "explicit"
} },
"editor.defaultFormatter": "golang.go"
}, },
"gopls": { "gopls": {
"usePlaceholders": true, "usePlaceholders": true,

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@@ -12,6 +13,7 @@ import (
"novamd/internal/api" "novamd/internal/api"
"novamd/internal/db" "novamd/internal/db"
"novamd/internal/filesystem" "novamd/internal/filesystem"
"novamd/internal/user"
) )
func main() { func main() {
@@ -36,16 +38,15 @@ func main() {
workdir = "./data" workdir = "./data"
} }
settings, err := database.GetSettings(1) // Assuming user ID 1 for now fs := filesystem.New(workdir)
if err != nil {
log.Print("Settings not found, using default settings")
}
fs := filesystem.New(workdir, &settings)
if settings.Settings.GitEnabled { // User service
if err := fs.InitializeGitRepo(); err != nil { userService := user.NewUserService(database, fs)
log.Fatal(err)
} // Admin user
_, err = userService.SetupAdminUser()
if err != nil {
log.Fatal(err)
} }
// Set up router // Set up router
@@ -64,21 +65,27 @@ func main() {
staticPath = "../frontend/dist" staticPath = "../frontend/dist"
} }
fileServer := http.FileServer(http.Dir(staticPath)) fileServer := http.FileServer(http.Dir(staticPath))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) { r.Get(
requestedPath := r.URL.Path "/*",
validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath) func(w http.ResponseWriter, r *http.Request) {
if err != nil { requestedPath := r.URL.Path
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
_, err = os.Stat(validatedPath) fullPath := filepath.Join(staticPath, requestedPath)
if os.IsNotExist(err) { cleanPath := filepath.Clean(fullPath)
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
return if !strings.HasPrefix(cleanPath, staticPath) {
} http.Error(w, "Invalid path", http.StatusBadRequest)
http.StripPrefix("/", fileServer).ServeHTTP(w, r) 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 // Start server
port := os.Getenv("NOVAMD_PORT") port := os.Getenv("NOVAMD_PORT")
@@ -87,4 +94,4 @@ func main() {
} }
log.Printf("Server starting on port %s", port) log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, r)) log.Fatal(http.ListenAndServe(":"+port, r))
} }

View File

@@ -7,6 +7,7 @@ require (
github.com/go-git/go-git/v5 v5.12.0 github.com/go-git/go-git/v5 v5.12.0
github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/validator/v10 v10.22.1
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
golang.org/x/crypto v0.21.0
) )
require ( require (
@@ -29,7 +30,6 @@ require (
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // 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/mod v0.12.0 // indirect
golang.org/x/net v0.23.0 // indirect golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect

View File

@@ -0,0 +1,170 @@
package api
import (
"encoding/json"
"io"
"net/http"
"novamd/internal/db"
"novamd/internal/filesystem"
"github.com/go-chi/chi/v5"
)
func ListFiles(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
}
files, err := fs.ListFilesRecursively(userID, workspaceID)
if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError)
return
}
respondJSON(w, files)
}
}
func LookupFileByName(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
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
filePaths, err := fs.FindFileByName(userID, workspaceID, filename)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
respondJSON(w, map[string][]string{"paths": filePaths})
}
}
func GetFileContent(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
}
filePath := chi.URLParam(r, "*")
content, err := fs.GetFileContent(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to read file", http.StatusNotFound)
return
}
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) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = fs.SaveFile(userID, workspaceID, filePath, content)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "File saved successfully"})
}
}
func DeleteFile(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
}
filePath := chi.URLParam(r, "*")
err = fs.DeleteFile(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("File deleted successfully"))
}
}
func GetLastOpenedFile(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _, err := getUserAndWorkspaceIDs(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, map[string]string{"lastOpenedFile": user.LastOpenedFilePath})
}
}
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
FilePath string `json:"filePath"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate that the file path is valid within the workspace
_, err = fs.ValidatePath(userID, workspaceID, requestBody.FilePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
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"})
}
}

View File

@@ -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"})
}
}

View File

@@ -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)
}
}

View File

@@ -1,235 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
files, err := fs.ListFilesRecursively()
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)
}
}
}
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filenameOrPath := r.URL.Query().Get("filename")
if filenameOrPath == "" {
http.Error(w, "Filename or path is required", http.StatusBadRequest)
return
}
filePaths, err := fs.FindFileByName(filenameOrPath)
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)
}
}
}
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
content, err := fs.GetFileContent(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)
}
}
}
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = fs.SaveFile(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)
}
}
}
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
err := fs.DeleteFile(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)
}
}
}
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)
if err != nil {
http.Error(w, "Invalid userId", http.StatusBadRequest)
return
}
settings, err := db.GetSettings(userID)
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)
}
}
}
func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var settings models.Settings
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)
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)
if err != nil {
http.Error(w, "Failed to setup git repo", http.StatusInternalServerError)
}
} 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
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(savedSettings); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
}
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var requestBody struct {
Message string `json:"message"`
}
err := json.NewDecoder(r.Body).Decode(&requestBody)
if 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(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)
}
}
}
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
err := fs.Pull()
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)
}
}
}

View File

@@ -9,20 +9,42 @@ import (
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Route("/", func(r chi.Router) { r.Route("/", func(r chi.Router) {
r.Route("/settings", func(r chi.Router) { // User routes
r.Get("/", GetSettings(db)) r.Route("/users/{userId}", func(r chi.Router) {
r.Post("/", UpdateSettings(db, fs)) r.Get("/", GetUser(db))
})
r.Route("/files", func(r chi.Router) { // Workspace routes
r.Get("/", ListFiles(fs)) r.Route("/workspaces", func(r chi.Router) {
r.Get("/*", GetFileContent(fs)) r.Get("/", ListWorkspaces(db))
r.Post("/*", SaveFile(fs)) r.Post("/", CreateWorkspace(db))
r.Delete("/*", DeleteFile(fs)) r.Get("/last", GetLastWorkspace(db))
r.Get("/lookup", LookupFileByName(fs)) r.Put("/last", UpdateLastWorkspace(db))
})
r.Route("/git", func(r chi.Router) { r.Route("/{workspaceId}", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs)) r.Get("/", GetWorkspace(db))
r.Post("/pull", PullChanges(fs)) r.Put("/", UpdateWorkspace(db, fs))
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.Post("/*", SaveFile(fs))
r.Get("/*", GetFileContent(fs))
r.Delete("/*", DeleteFile(fs))
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
})
})
}) })
}) })
} }

View File

@@ -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)
}
}

View File

@@ -0,0 +1,242 @@
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 workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
workspace.UserID = userID
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, workspace)
}
}
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 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 currentWorkspace.UserID != userID {
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
return
}
// 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, workspace)
}
}
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
}
// Check if this is the user's last workspace
workspaces, err := db.GetWorkspacesByUserID(userID)
if err != nil {
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError)
return
}
if len(workspaces) <= 1 {
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
return
}
// 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
}
// 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})
}
}
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"})
}
}

View File

@@ -13,10 +13,37 @@ type Migration struct {
var migrations = []Migration{ var migrations = []Migration{
{ {
Version: 1, Version: 1,
SQL: `CREATE TABLE IF NOT EXISTS settings ( SQL: `
user_id INTEGER PRIMARY KEY, -- Create users table
settings JSON NOT NULL CREATE TABLE IF NOT EXISTS users (
)`, id INTEGER PRIMARY KEY AUTOINCREMENT,
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
);
-- 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)
);
`,
}, },
} }

View File

@@ -1,45 +0,0 @@
package db
import (
"database/sql"
"encoding/json"
"novamd/internal/models"
)
func (db *DB) GetSettings(userID int) (models.Settings, error) {
var settings models.Settings
var settingsJSON []byte
err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON)
if err != nil {
if err == sql.ErrNoRows {
// If no settings found, return default settings
settings.UserID = userID
settings.Settings = models.UserSettings{} // This will be filled with defaults later
return settings, nil
}
return settings, err
}
err = json.Unmarshal(settingsJSON, &settings.Settings)
if err != nil {
return settings, err
}
return settings, nil
}
func (db *DB) SaveSettings(settings models.Settings) error {
if err := settings.Validate(); err != nil {
return err
}
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))
return err
}

View File

@@ -0,0 +1,168 @@
package db
import (
"database/sql"
"novamd/internal/models"
)
func (db *DB) CreateUser(user *models.User) error {
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 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
}
// 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,
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) GetUserByID(id int) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
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)
if err != nil {
return nil, err
}
return user, nil
}
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
user := &models.User{}
var lastOpenedFilePath sql.NullString
err := db.QueryRow(`
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)
if err != nil {
return nil, err
}
if lastOpenedFilePath.Valid {
user.LastOpenedFilePath = lastOpenedFilePath.String
} else {
user.LastOpenedFilePath = ""
}
return user, nil
}
func (db *DB) UpdateUser(user *models.User) error {
_, err := db.Exec(`
UPDATE users
SET email = ?, display_name = ?, role = ?, last_workspace_id = ?, last_opened_file_path = ?
WHERE id = ?`,
user.Email, user.DisplayName, user.Role, 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 {
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) {
var workspaceID int
err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID)
return workspaceID, err
}

View File

@@ -0,0 +1,162 @@
package db
import (
"database/sql"
"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
}
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
}

View File

@@ -4,17 +4,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"novamd/internal/gitutils" "novamd/internal/gitutils"
"novamd/internal/models"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
) )
type FileSystem struct { type FileSystem struct {
RootDir string RootDir string
GitRepo *gitutils.GitRepo GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
Settings *models.Settings
} }
type FileNode struct { type FileNode struct {
@@ -24,67 +21,59 @@ type FileNode struct {
Children []FileNode `json:"children,omitempty"` Children []FileNode `json:"children,omitempty"`
} }
func New(rootDir string, settings *models.Settings) *FileSystem { func New(rootDir string) *FileSystem {
fs := &FileSystem{ return &FileSystem{
RootDir: rootDir, RootDir: rootDir,
Settings: settings, GitRepos: make(map[int]map[int]*gitutils.GitRepo),
}
}
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)
} }
if settings.Settings.GitEnabled { return nil
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 { func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir) workspacePath := fs.GetWorkspacePath(userID, workspaceID)
return fs.InitializeGitRepo() fullPath := filepath.Join(workspacePath, path)
}
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)
cleanPath := filepath.Clean(fullPath) cleanPath := filepath.Clean(fullPath)
if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) { if !strings.HasPrefix(cleanPath, workspacePath) {
return "", fmt.Errorf("invalid path: outside of root directory") return "", fmt.Errorf("invalid path: outside of workspace")
}
relPath, err := filepath.Rel(rootDir, cleanPath)
if err != nil {
return "", err
}
if strings.HasPrefix(relPath, "..") {
return "", fmt.Errorf("invalid path: outside of root directory")
} }
return cleanPath, nil return cleanPath, nil
} }
func (fs *FileSystem) validatePath(path string) (string, error) { func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
return ValidatePath(fs.RootDir, path) workspacePath := fs.GetWorkspacePath(userID, workspaceID)
} return fs.walkDirectory(workspacePath, "")
func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) {
return fs.walkDirectory(fs.RootDir, "")
} }
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
@@ -93,67 +82,46 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
return nil, err return nil, err
} }
var folders []FileNode nodes := make([]FileNode, 0)
var files []FileNode
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
path := filepath.Join(prefix, name) path := filepath.Join(prefix, name)
fullPath := filepath.Join(dir, name) fullPath := filepath.Join(dir, name)
node := FileNode{
ID: path,
Name: name,
Path: path,
}
if entry.IsDir() { if entry.IsDir() {
children, err := fs.walkDirectory(fullPath, path) children, err := fs.walkDirectory(fullPath, path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
folders = append(folders, FileNode{ node.Children = children
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,
})
} }
nodes = append(nodes, node)
} }
// Sort folders and files alphabetically return nodes, nil
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
} }
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
var foundPaths []string var foundPaths []string
var searchPattern string workspacePath := fs.GetWorkspacePath(userID, workspaceID)
// If no extension is provided, assume .md err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
if !strings.Contains(filenameOrPath, ".") {
searchPattern = filenameOrPath + ".md"
} else {
searchPattern = filenameOrPath
}
err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if !info.IsDir() { if !info.IsDir() {
relPath, err := filepath.Rel(fs.RootDir, path) relPath, err := filepath.Rel(workspacePath, path)
if err != nil { if err != nil {
return err return err
} }
if strings.EqualFold(info.Name(), filename) {
// Check if the file matches the search pattern
if strings.HasSuffix(relPath, searchPattern) ||
strings.EqualFold(info.Name(), searchPattern) {
foundPaths = append(foundPaths, relPath) foundPaths = append(foundPaths, relPath)
} }
} }
@@ -171,16 +139,16 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
return foundPaths, nil return foundPaths, nil
} }
func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return os.ReadFile(fullPath) return os.ReadFile(fullPath)
} }
func (fs *FileSystem) SaveFile(filePath string, content []byte) error { func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
} }
@@ -193,30 +161,64 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
return os.WriteFile(fullPath, content, 0644) return os.WriteFile(fullPath, content, 0644)
} }
func (fs *FileSystem) DeleteFile(filePath string) error { func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
} }
return os.Remove(fullPath) return os.Remove(fullPath)
} }
func (fs *FileSystem) StageCommitAndPush(message string) error { func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
if fs.GitRepo == nil { dir := fs.GetWorkspacePath(userID, workspaceID)
return errors.New("git settings not configured") return os.MkdirAll(dir, 0755)
}
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(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(userID, workspaceID int, message string) error {
repo, ok := fs.getGitRepo(userID, workspaceID)
if !ok {
return errors.New("git settings not configured for this workspace")
} }
if err := fs.GitRepo.Commit(message); err != nil { if err := repo.Commit(message); err != nil {
return err return err
} }
return fs.GitRepo.Push() return repo.Push()
} }
func (fs *FileSystem) Pull() error { func (fs *FileSystem) Pull(userID, workspaceID int) error {
if fs.GitRepo == nil { repo, ok := fs.getGitRepo(userID, workspaceID)
return errors.New("git settings not configured") if !ok {
return errors.New("git settings not configured for this workspace")
} }
return fs.GitRepo.Pull() 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
} }

View File

@@ -1,58 +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 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}",
}
var validate = validator.New()
func (s *Settings) 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 (s *Settings) UnmarshalJSON(data []byte) error {
type Alias Settings
aux := &struct {
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
return s.Validate()
}

View File

@@ -0,0 +1,32 @@
package models
import (
"time"
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
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"`
}
func (u *User) Validate() error {
return validate.Struct(u)
}

View File

@@ -0,0 +1,37 @@
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"`
// 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}"
}

View File

@@ -0,0 +1,120 @@
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 adminUser != 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)
}
// 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)
}
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
user, err := s.DB.GetUserByID(id)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// 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.FS.DeleteUserWorkspace(user.ID, workspace.ID)
if err != nil {
return fmt.Errorf("failed to delete workspace files: %w", err)
}
}
// Delete user from database (this will cascade delete workspaces)
return s.DB.DeleteUser(id)
}

View File

@@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import Layout from './components/Layout'; import Layout from './components/Layout';
import { SettingsProvider, useSettings } from './contexts/SettingsContext'; import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext'; import { ModalProvider } from './contexts/ModalContext';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import './App.scss'; import './App.scss';
function AppContent() { function AppContent() {
const { loading } = useSettings();
if (loading) {
return <div>Loading...</div>;
}
return <Layout />; return <Layout />;
} }
@@ -26,11 +20,11 @@ function App() {
<MantineProvider defaultColorScheme="light"> <MantineProvider defaultColorScheme="light">
<Notifications /> <Notifications />
<ModalsProvider> <ModalsProvider>
<SettingsProvider> <WorkspaceProvider>
<ModalProvider> <ModalProvider>
<AppContent /> <AppContent />
</ModalProvider> </ModalProvider>
</SettingsProvider> </WorkspaceProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</> </>

View File

@@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
const { settings } = useSettings(); const { colorScheme } = useWorkspace();
const editorRef = useRef(); const editorRef = useRef();
const viewRef = useRef(); const viewRef = useRef();
@@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
overflow: 'auto', overflow: 'auto',
}, },
'.cm-gutters': { '.cm-gutters': {
backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5', backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
color: settings.theme === 'dark' ? '#858585' : '#999', color: colorScheme === 'dark' ? '#858585' : '#999',
border: 'none', border: 'none',
}, },
'.cm-activeLineGutter': { '.cm-activeLineGutter': {
backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8', backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
}, },
}); });
@@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
} }
}), }),
theme, theme,
settings.theme === 'dark' ? oneDark : [], colorScheme === 'dark' ? oneDark : [],
], ],
}); });
@@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => { return () => {
view.destroy(); view.destroy();
}; };
}, [settings.theme, handleContentChange]); }, [colorScheme, handleContentChange]);
useEffect(() => { useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) { if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

@@ -6,11 +6,11 @@ import {
IconGitPullRequest, IconGitPullRequest,
IconGitCommit, IconGitCommit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useSettings } from '../contexts/SettingsContext';
import { useModalContext } from '../contexts/ModalContext'; import { useModalContext } from '../contexts/ModalContext';
import { useWorkspace } from '../contexts/WorkspaceContext';
const FileActions = ({ handlePullChanges, selectedFile }) => { const FileActions = ({ handlePullChanges, selectedFile }) => {
const { settings } = useSettings(); const { settings } = useWorkspace();
const { const {
setNewFileModalVisible, setNewFileModalVisible,
setDeleteFileModalVisible, setDeleteFileModalVisible,

View File

@@ -1,24 +1,17 @@
import React from 'react'; import React from 'react';
import { Group, Text, ActionIcon, Avatar } from '@mantine/core'; import { Group, Text, Avatar } from '@mantine/core';
import { IconSettings } from '@tabler/icons-react'; import WorkspaceSwitcher from './WorkspaceSwitcher';
import Settings from './Settings'; import Settings from './Settings';
import { useModalContext } from '../contexts/ModalContext';
const Header = () => { const Header = () => {
const { setSettingsModalVisible } = useModalContext();
const openSettings = () => setSettingsModalVisible(true);
return ( return (
<Group justify="space-between" h={60} px="md"> <Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg"> <Text fw={700} size="lg">
NovaMD NovaMD
</Text> </Text>
<Group> <Group>
<WorkspaceSwitcher />
<Avatar src="https://via.placeholder.com/40" radius="xl" /> <Avatar src="https://via.placeholder.com/40" radius="xl" />
<ActionIcon variant="subtle" onClick={openSettings} size="lg">
<IconSettings size={24} />
</ActionIcon>
</Group> </Group>
<Settings /> <Settings />
</Group> </Group>

View File

@@ -1,16 +1,30 @@
import React from 'react'; import React from 'react';
import { AppShell, Container } from '@mantine/core'; import { AppShell, Container, Loader, Center } from '@mantine/core';
import Header from './Header'; import Header from './Header';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import MainContent from './MainContent'; import MainContent from './MainContent';
import { useFileNavigation } from '../hooks/useFileNavigation'; import { useFileNavigation } from '../hooks/useFileNavigation';
import { useFileList } from '../hooks/useFileList'; import { useFileList } from '../hooks/useFileList';
import { useWorkspace } from '../contexts/WorkspaceContext';
const Layout = () => { const Layout = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect, handleLinkClick } = const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation(); useFileNavigation();
const { files, loadFileList } = useFileList(); const { files, loadFileList } = useFileList();
if (workspaceLoading) {
return (
<Center style={{ height: '100vh' }}>
<Loader size="xl" />
</Center>
);
}
if (!currentWorkspace) {
return <div>No workspace found. Please create a workspace.</div>;
}
return ( return (
<AppShell header={{ height: 60 }} padding="md"> <AppShell header={{ height: 60 }} padding="md">
<AppShell.Header> <AppShell.Header>

View File

@@ -10,7 +10,7 @@ import CommitMessageModal from './modals/CommitMessageModal';
import { useFileContent } from '../hooks/useFileContent'; import { useFileContent } from '../hooks/useFileContent';
import { useFileOperations } from '../hooks/useFileOperations'; import { useFileOperations } from '../hooks/useFileOperations';
import { useGitOperations } from '../hooks/useGitOperations'; import { useGitOperations } from '../hooks/useGitOperations';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
const MainContent = ({ const MainContent = ({
selectedFile, selectedFile,
@@ -19,7 +19,7 @@ const MainContent = ({
loadFileList, loadFileList,
}) => { }) => {
const [activeTab, setActiveTab] = useState('source'); const [activeTab, setActiveTab] = useState('source');
const { settings } = useSettings(); const { settings } = useWorkspace();
const { const {
content, content,
hasUnsavedChanges, hasUnsavedChanges,

View File

@@ -1,11 +1,21 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react'; import React, { useReducer, useEffect, useCallback, useRef } from 'react';
import { Modal, Badge, Button, Group, Title } from '@mantine/core'; import {
Modal,
Badge,
Button,
Group,
Title,
Stack,
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
import AppearanceSettings from './settings/AppearanceSettings'; import AppearanceSettings from './settings/AppearanceSettings';
import EditorSettings from './settings/EditorSettings'; import EditorSettings from './settings/EditorSettings';
import GitSettings from './settings/GitSettings'; import GitSettings from './settings/GitSettings';
import GeneralSettings from './settings/GeneralSettings';
import { useModalContext } from '../contexts/ModalContext'; import { useModalContext } from '../contexts/ModalContext';
import DangerZoneSettings from './settings/DangerZoneSettings';
const initialState = { const initialState = {
localSettings: {}, localSettings: {},
@@ -38,19 +48,19 @@ function settingsReducer(state, action) {
initialSettings: state.localSettings, initialSettings: state.localSettings,
hasUnsavedChanges: false, hasUnsavedChanges: false,
}; };
case 'RESET':
return {
...state,
localSettings: state.initialSettings,
hasUnsavedChanges: false,
};
default: default:
return state; return state;
} }
} }
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
const Settings = () => { const Settings = () => {
const { settings, updateSettings, colorScheme } = useSettings(); const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
@@ -58,16 +68,20 @@ const Settings = () => {
useEffect(() => { useEffect(() => {
if (isInitialMount.current) { if (isInitialMount.current) {
isInitialMount.current = false; isInitialMount.current = false;
const settings = {
name: currentWorkspace.name,
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 }); dispatch({ type: 'INIT_SETTINGS', payload: settings });
} }
}, [settings]); }, [currentWorkspace]);
useEffect(() => {
dispatch({
type: 'UPDATE_LOCAL_SETTINGS',
payload: { theme: colorScheme },
});
}, [colorScheme]);
const handleInputChange = useCallback((key, value) => { const handleInputChange = useCallback((key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
@@ -75,6 +89,14 @@ const Settings = () => {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
if (!state.localSettings.name?.trim()) {
notifications.show({
message: 'Workspace name cannot be empty',
color: 'red',
});
return;
}
await updateSettings(state.localSettings); await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' }); dispatch({ type: 'MARK_SAVED' });
notifications.show({ notifications.show({
@@ -92,11 +114,8 @@ const Settings = () => {
}; };
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (state.hasUnsavedChanges) {
dispatch({ type: 'RESET' });
}
setSettingsModalVisible(false); setSettingsModalVisible(false);
}, [state.hasUnsavedChanges, setSettingsModalVisible]); }, [setSettingsModalVisible]);
return ( return (
<Modal <Modal
@@ -106,34 +125,105 @@ const Settings = () => {
centered centered
size="lg" size="lg"
> >
{state.hasUnsavedChanges && ( <Stack spacing="xl">
<Badge color="yellow" variant="light" mb="md"> {state.hasUnsavedChanges && (
Unsaved Changes <Badge color="yellow" variant="light">
</Badge> Unsaved Changes
)} </Badge>
<AppearanceSettings )}
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) => handleInputChange('theme', newTheme)} <Accordion
/> defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
<EditorSettings multiple
autoSave={state.localSettings.autoSave} styles={(theme) => ({
onAutoSaveChange={(value) => handleInputChange('autoSave', value)} control: {
/> paddingTop: theme.spacing.md,
<GitSettings paddingBottom: theme.spacing.md,
gitEnabled={state.localSettings.gitEnabled} },
gitUrl={state.localSettings.gitUrl} item: {
gitUser={state.localSettings.gitUser} borderBottom: `1px solid ${
gitToken={state.localSettings.gitToken} theme.colorScheme === 'dark'
gitAutoCommit={state.localSettings.gitAutoCommit} ? theme.colors.dark[4]
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate} : theme.colors.gray[3]
onInputChange={handleInputChange} }`,
/> '&[data-active]': {
<Group justify="flex-end" mt="xl"> backgroundColor:
<Button variant="default" onClick={handleClose}> theme.colorScheme === 'dark'
Cancel ? theme.colors.dark[7]
</Button> : theme.colors.gray[0],
<Button onClick={handleSubmit}>Save Changes</Button> },
</Group> },
chevron: {
'&[data-rotate]': {
transform: 'rotate(180deg)',
},
},
})}
>
<Accordion.Item value="general">
<AccordionControl>General</AccordionControl>
<Accordion.Panel>
<GeneralSettings
name={state.localSettings.name}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="appearance">
<AccordionControl>Appearance</AccordionControl>
<Accordion.Panel>
<AppearanceSettings
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) =>
handleInputChange('theme', newTheme)
}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="editor">
<AccordionControl>Editor</AccordionControl>
<Accordion.Panel>
<EditorSettings
autoSave={state.localSettings.autoSave}
onAutoSaveChange={(value) =>
handleInputChange('autoSave', value)
}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="git">
<AccordionControl>Git Integration</AccordionControl>
<Accordion.Panel>
<GitSettings
gitEnabled={state.localSettings.gitEnabled}
gitUrl={state.localSettings.gitUrl}
gitUser={state.localSettings.gitUser}
gitToken={state.localSettings.gitToken}
gitAutoCommit={state.localSettings.gitAutoCommit}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel>
<DangerZoneSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="flex-end">
<Button variant="default" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save Changes</Button>
</Group>
</Stack>
</Modal> </Modal>
); );
}; };

View File

@@ -3,10 +3,10 @@ import { Box } from '@mantine/core';
import FileActions from './FileActions'; import FileActions from './FileActions';
import FileTree from './FileTree'; import FileTree from './FileTree';
import { useGitOperations } from '../hooks/useGitOperations'; import { useGitOperations } from '../hooks/useGitOperations';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
const { settings } = useSettings(); const { settings } = useWorkspace();
const { handlePull } = useGitOperations(settings.gitEnabled); const { handlePull } = useGitOperations(settings.gitEnabled);
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import {
Box,
Popover,
Stack,
Paper,
ScrollArea,
Group,
UnstyledButton,
Text,
Loader,
Center,
ActionIcon,
Tooltip,
useMantineTheme,
} 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';
import CreateWorkspaceModal from './modals/CreateWorkspaceModal';
const WorkspaceSwitcher = () => {
const { currentWorkspace, switchWorkspace } = useWorkspace();
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(false);
const [popoverOpened, setPopoverOpened] = useState(false);
const theme = useMantineTheme();
const loadWorkspaces = async () => {
setLoading(true);
try {
const list = await listWorkspaces();
setWorkspaces(list);
} catch (error) {
console.error('Failed to load workspaces:', error);
}
setLoading(false);
};
const handleCreateWorkspace = () => {
setPopoverOpened(false);
setCreateWorkspaceModalVisible(true);
};
const handleWorkspaceCreated = async (newWorkspace) => {
await loadWorkspaces();
switchWorkspace(newWorkspace.id);
};
return (
<>
<Popover
width={300}
position="bottom-start"
shadow="md"
opened={popoverOpened}
onChange={setPopoverOpened}
>
<Popover.Target>
<UnstyledButton
onClick={() => {
setPopoverOpened((o) => !o);
if (!popoverOpened) {
loadWorkspaces();
}
}}
>
<Group gap="xs">
<IconFolders size={20} />
<div>
<Text size="sm" fw={500}>
{currentWorkspace?.name || 'No workspace'}
</Text>
</div>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group justify="space-between" mb="md" px="xs">
<Text size="sm" fw={600}>
Workspaces
</Text>
<Tooltip label="Create New Workspace">
<ActionIcon
variant="default"
size="md"
onClick={handleCreateWorkspace}
>
<IconFolderPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea.Autosize mah={400} offsetScrollbars>
<Stack gap="xs">
{loading ? (
<Center p="md">
<Loader size="sm" />
</Center>
) : (
workspaces.map((workspace) => {
const isSelected = workspace.id === currentWorkspace?.id;
return (
<Paper
key={workspace.id}
p="xs"
withBorder
style={{
backgroundColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 7 : 5
]
: undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.id);
setPopoverOpened(false);
}}
>
<Box>
<Text
size="sm"
fw={500}
truncate
c={
isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 0 : 9
]
: undefined
}
>
{workspace.name}
</Text>
<Text
size="xs"
c={
isSelected
? theme.colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7]
: 'dimmed'
}
>
{new Date(
workspace.createdAt
).toLocaleDateString()}
</Text>
</Box>
</UnstyledButton>
{isSelected && (
<Tooltip label="Workspace Settings">
<ActionIcon
variant="subtle"
size="lg"
color={
theme.colorScheme === 'dark'
? 'blue.2'
: 'blue.7'
}
onClick={(e) => {
e.stopPropagation();
setSettingsModalVisible(true);
setPopoverOpened(false);
}}
>
<IconSettings size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Paper>
);
})
)}
</Stack>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
<CreateWorkspaceModal onWorkspaceCreated={handleWorkspaceCreated} />
</>
);
};
export default WorkspaceSwitcher;

View File

@@ -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 (
<Modal
opened={createWorkspaceModalVisible}
onClose={() => setCreateWorkspaceModalVisible(false)}
title="Create New Workspace"
centered
size="sm"
>
<Box maw={400} mx="auto">
<TextInput
label="Workspace Name"
placeholder="Enter workspace name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
mb="md"
w="100%"
disabled={loading}
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setCreateWorkspaceModalVisible(false)}
disabled={loading}
>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Create
</Button>
</Group>
</Box>
</Modal>
);
};
export default CreateWorkspaceModal;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteWorkspaceModal = ({
opened,
onClose,
onConfirm,
workspaceName,
}) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete Workspace"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete workspace "{workspaceName}"? This action
cannot be undone and all files in this workspace will be permanently
deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm}>
Delete Workspace
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteWorkspaceModal;

View File

@@ -1,20 +1,18 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core'; import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useSettings } from '../../contexts/SettingsContext'; import { useWorkspace } from '../../contexts/WorkspaceContext';
const AppearanceSettings = ({ onThemeChange }) => { const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, toggleColorScheme } = useSettings(); const { colorScheme, updateColorScheme } = useWorkspace();
const handleThemeChange = () => { const handleThemeChange = () => {
toggleColorScheme(); const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
onThemeChange(colorScheme === 'dark' ? 'light' : 'dark'); updateColorScheme(newTheme);
onThemeChange(newTheme);
}; };
return ( return (
<Box mb="md"> <Box mb="md">
<Title order={3} mb="md">
Appearance
</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text> <Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} /> <Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />

View File

@@ -0,0 +1,46 @@
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, workspaces, deleteCurrentWorkspace } =
useWorkspace();
const { setSettingsModalVisible } = useModalContext();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const handleDelete = async () => {
await deleteCurrentWorkspace();
setDeleteModalOpened(false);
setSettingsModalVisible(false);
};
return (
<Box mb="md">
<Button
color="red"
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>
<DeleteWorkspaceModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
workspaceName={currentWorkspace?.name}
/>
</Box>
);
};
export default DangerZoneSettings;

View File

@@ -4,9 +4,6 @@ import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
const EditorSettings = ({ autoSave, onAutoSaveChange }) => { const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
return ( return (
<Box mb="md"> <Box mb="md">
<Title order={3} mb="md">
Editor
</Title>
<Tooltip label="Auto Save feature is coming soon!" position="left"> <Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="sm">Auto Save</Text> <Text size="sm">Auto Save</Text>

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
const GeneralSettings = ({ name, onInputChange }) => {
return (
<Box mb="md">
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Workspace Name</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={name || ''}
onChange={(event) =>
onInputChange('name', event.currentTarget.value)
}
placeholder="Enter workspace name"
required
/>
</Grid.Col>
</Grid>
</Box>
);
};
export default GeneralSettings;

View File

@@ -21,7 +21,6 @@ const GitSettings = ({
}) => { }) => {
return ( return (
<Stack spacing="md"> <Stack spacing="md">
<Title order={3}>Git Integration</Title>
<Grid gutter="md" align="center"> <Grid gutter="md" align="center">
<Grid.Col span={6}> <Grid.Col span={6}>
<Text size="sm">Enable Git</Text> <Text size="sm">Enable Git</Text>

View File

@@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => {
const [commitMessageModalVisible, setCommitMessageModalVisible] = const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false); useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value = { const value = {
newFileModalVisible, newFileModalVisible,
@@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => {
setCommitMessageModalVisible, setCommitMessageModalVisible,
settingsModalVisible, settingsModalVisible,
setSettingsModalVisible, setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
}; };
return ( return (

View File

@@ -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 (
<SettingsContext.Provider value={contextValue}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -0,0 +1,211 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceId,
getWorkspace,
updateWorkspace,
updateLastWorkspace,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
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);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
}, []);
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 {
const { lastWorkspaceId } = await fetchLastWorkspaceId();
if (lastWorkspaceId) {
await loadWorkspaceData(lastWorkspaceId);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
initializeWorkspace();
}, []);
const switchWorkspace = useCallback(async (workspaceId) => {
try {
setLoading(true);
await updateLastWorkspace(workspaceId);
await loadWorkspaceData(workspaceId);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to switch workspace',
color: 'red',
});
} finally {
setLoading(false);
}
}, []);
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;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.id,
updatedWorkspace
);
setCurrentWorkspace(response);
setColorScheme(response.theme);
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, setColorScheme]
);
const updateColorScheme = useCallback(
(newTheme) => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
return (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
);
};
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (context === undefined) {
throw new Error('useWorkspace must be used within a WorkspaceProvider');
}
return context;
};

View File

@@ -2,38 +2,45 @@ import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api'; import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers'; import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants'; import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileContent = (selectedFile) => { export const useFileContent = (selectedFile) => {
const { currentWorkspace } = useWorkspace();
const [content, setContent] = useState(DEFAULT_FILE.content); const [content, setContent] = useState(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const loadFileContent = useCallback(async (filePath) => { const loadFileContent = useCallback(
try { async (filePath) => {
let newContent; if (!currentWorkspace) return;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content; try {
} else if (!isImageFile(filePath)) { let newContent;
newContent = await fetchFileContent(filePath); if (filePath === DEFAULT_FILE.path) {
} else { newContent = DEFAULT_FILE.content;
newContent = ''; // Set empty content for image files } 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); [currentWorkspace]
setHasUnsavedChanges(false); );
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
}
}, []);
useEffect(() => { useEffect(() => {
if (selectedFile) { if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile); loadFileContent(selectedFile);
} }
}, [selectedFile, loadFileContent]); }, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback( const handleContentChange = useCallback(
(newContent) => { (newContent) => {

View File

@@ -1,12 +1,16 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { fetchFileList } from '../services/api'; import { fetchFileList } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileList = () => { export const useFileList = () => {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const loadFileList = useCallback(async () => { const loadFileList = useCallback(async () => {
if (!currentWorkspace || workspaceLoading) return;
try { try {
const fileList = await fetchFileList(); const fileList = await fetchFileList(currentWorkspace.id);
if (Array.isArray(fileList)) { if (Array.isArray(fileList)) {
setFiles(fileList); setFiles(fileList);
} else { } else {
@@ -14,8 +18,9 @@ export const useFileList = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to load file list:', error); console.error('Failed to load file list:', error);
setFiles([]);
} }
}, []); }, [currentWorkspace]);
return { files, loadFileList }; return { files, loadFileList };
}; };

View File

@@ -2,10 +2,12 @@ import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { lookupFileByName } from '../services/api'; import { lookupFileByName } from '../services/api';
import { DEFAULT_FILE } from '../utils/constants'; import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileNavigation = () => { export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true); const [isNewFile, setIsNewFile] = useState(true);
const { currentWorkspace } = useWorkspace();
const handleFileSelect = useCallback((filePath) => { const handleFileSelect = useCallback((filePath) => {
setSelectedFile(filePath); setSelectedFile(filePath);
@@ -14,8 +16,10 @@ export const useFileNavigation = () => {
const handleLinkClick = useCallback( const handleLinkClick = useCallback(
async (filename) => { async (filename) => {
if (!currentWorkspace) return;
try { try {
const filePaths = await lookupFileByName(filename); const filePaths = await lookupFileByName(currentWorkspace.id, filename);
if (filePaths.length >= 1) { if (filePaths.length >= 1) {
handleFileSelect(filePaths[0]); handleFileSelect(filePaths[0]);
} else { } else {
@@ -34,7 +38,7 @@ export const useFileNavigation = () => {
}); });
} }
}, },
[handleFileSelect] [currentWorkspace]
); );
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect }; return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };

View File

@@ -1,12 +1,12 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api'; import { saveFileContent, deleteFile } from '../services/api';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
import { useGitOperations } from './useGitOperations'; import { useGitOperations } from './useGitOperations';
export const useFileOperations = () => { export const useFileOperations = () => {
const { settings } = useSettings(); const { currentWorkspace, settings } = useWorkspace();
const { handleCommitAndPush } = useGitOperations(settings.gitEnabled); const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback( const autoCommit = useCallback(
async (filePath, action) => { async (filePath, action) => {
@@ -15,20 +15,21 @@ export const useFileOperations = () => {
.replace('${filename}', filePath) .replace('${filename}', filePath)
.replace('${action}', action); .replace('${action}', action);
// Capitalize the first letter of the commit message
commitMessage = commitMessage =
commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1); commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
await handleCommitAndPush(commitMessage); await handleCommitAndPush(commitMessage);
} }
}, },
[settings, handleCommitAndPush] [settings]
); );
const handleSave = useCallback( const handleSave = useCallback(
async (filePath, content) => { async (filePath, content) => {
if (!currentWorkspace) return false;
try { try {
await saveFileContent(filePath, content); await saveFileContent(currentWorkspace.id, filePath, content);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'File saved successfully', message: 'File saved successfully',
@@ -46,13 +47,15 @@ export const useFileOperations = () => {
return false; return false;
} }
}, },
[autoCommit] [currentWorkspace, autoCommit]
); );
const handleDelete = useCallback( const handleDelete = useCallback(
async (filePath) => { async (filePath) => {
if (!currentWorkspace) return false;
try { try {
await deleteFile(filePath); await deleteFile(currentWorkspace.id, filePath);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'File deleted successfully', message: 'File deleted successfully',
@@ -70,13 +73,15 @@ export const useFileOperations = () => {
return false; return false;
} }
}, },
[autoCommit] [currentWorkspace, autoCommit]
); );
const handleCreate = useCallback( const handleCreate = useCallback(
async (fileName, initialContent = '') => { async (fileName, initialContent = '') => {
if (!currentWorkspace) return false;
try { try {
await saveFileContent(fileName, initialContent); await saveFileContent(currentWorkspace.id, fileName, initialContent);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'File created successfully', message: 'File created successfully',
@@ -94,7 +99,7 @@ export const useFileOperations = () => {
return false; return false;
} }
}, },
[autoCommit] [currentWorkspace, autoCommit]
); );
return { handleSave, handleDelete, handleCreate }; return { handleSave, handleDelete, handleCreate };

View File

@@ -1,12 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api'; 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 () => { const handlePull = useCallback(async () => {
if (!gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;
try { try {
await pullChanges(); await pullChanges(currentWorkspace.id);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully pulled latest changes', message: 'Successfully pulled latest changes',
@@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => {
}); });
return false; return false;
} }
}, [gitEnabled]); }, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback( const handleCommitAndPush = useCallback(
async (message) => { async (message) => {
if (!gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;
try { try {
await commitAndPush(message); await commitAndPush(currentWorkspace.id, message);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully committed and pushed changes', message: 'Successfully committed and pushed changes',
@@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => {
return false; return false;
} }
}, },
[gitEnabled] [currentWorkspace, settings.gitEnabled]
); );
return { handlePull, handleCommitAndPush }; return { handlePull, handleCommitAndPush };

View File

@@ -16,76 +16,163 @@ const apiCall = async (url, options = {}) => {
} }
}; };
export const fetchFileList = async () => { export const fetchLastWorkspaceId = async () => {
const response = await apiCall(`${API_BASE_URL}/files`); const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`);
return response.json(); return response.json();
}; };
export const fetchFileContent = async (filePath) => { export const fetchFileList = async (workspaceId) => {
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) => {
const response = await apiCall( 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 getWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`
);
return response.json();
};
// 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}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
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(); const data = await response.json();
return data.paths; 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 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();
};

View File

@@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [
'.svg', '.svg',
]; ];
export const DEFAULT_SETTINGS = { // Renamed from DEFAULT_SETTINGS to be more specific
export const DEFAULT_WORKSPACE_SETTINGS = {
theme: THEMES.LIGHT, theme: THEMES.LIGHT,
autoSave: false, autoSave: false,
gitEnabled: false, gitEnabled: false,
@@ -37,6 +38,12 @@ export const DEFAULT_SETTINGS = {
gitCommitMsgTemplate: '${action} ${filename}', gitCommitMsgTemplate: '${action} ${filename}',
}; };
// Template for creating new workspaces
export const DEFAULT_WORKSPACE = {
name: '',
...DEFAULT_WORKSPACE_SETTINGS,
};
export const DEFAULT_FILE = { export const DEFAULT_FILE = {
name: 'New File.md', name: 'New File.md',
path: 'New File.md', path: 'New File.md',