From 48e2d9e0c06f434f837429abe7e5c31b035964ef Mon Sep 17 00:00:00 2001 From: tolgaulas Date: Sat, 1 Nov 2025 00:16:25 +0300 Subject: [PATCH] external default folder --- README.md | 2 + client/package-lock.json | 4 +- client/src/App.tsx | 95 +++++++++++++- client/src/components/FileExplorerPanel.tsx | 74 ++++++----- client/src/components/Sidebar.tsx | 15 ++- client/src/constants/mockFiles.ts | 110 ---------------- client/src/store/useFileStore.ts | 116 ++++++++++++----- client/src/types/editor.ts | 6 + default-files/index.html | 16 +++ default-files/script.js | 16 +++ default-files/style.css | 48 +++++++ docker-compose.yml | 3 + .../codecafe/backend/config/WebConfig.java | 40 +++++- .../controller/DefaultFileController.java | 36 +++++ .../codecafe/backend/dto/DefaultFileDTO.java | 54 ++++++++ .../backend/service/DefaultFileService.java | 123 ++++++++++++++++++ .../src/main/resources/application.properties | 4 + .../controller/DefaultFileControllerTest.java | 58 +++++++++ .../service/DefaultFileServiceTest.java | 75 +++++++++++ 19 files changed, 703 insertions(+), 192 deletions(-) delete mode 100644 client/src/constants/mockFiles.ts create mode 100644 default-files/index.html create mode 100644 default-files/script.js create mode 100644 default-files/style.css create mode 100644 server/src/main/java/com/codecafe/backend/controller/DefaultFileController.java create mode 100644 server/src/main/java/com/codecafe/backend/dto/DefaultFileDTO.java create mode 100644 server/src/main/java/com/codecafe/backend/service/DefaultFileService.java create mode 100644 server/src/test/java/com/codecafe/backend/controller/DefaultFileControllerTest.java create mode 100644 server/src/test/java/com/codecafe/backend/service/DefaultFileServiceTest.java diff --git a/README.md b/README.md index 4decf25..2c176cd 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ docker-compose up Access the app at http://localhost:80 +By default the backend mounts `./default-files` and exposes its contents through the editor. If you want to serve a different local project, edit the `server` volume mapping in `docker-compose.yml` (replace `./default-files` with your desired path) before running `docker-compose up`. + For detailed setup instructions, development guidelines, and contribution information, see [CONTRIBUTING.md](CONTRIBUTING.md). ## On the Horizon diff --git a/client/package-lock.json b/client/package-lock.json index ad0d99f..00e192f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,11 +1,11 @@ { - "name": "frontend", + "name": "client", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", + "name": "client", "version": "0.0.0", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index a667ad4..edd2554 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import { TerminalHandle, SearchOptions, MatchInfo, + EditorLanguageKey, } from "./types/editor"; import { @@ -25,7 +26,6 @@ import { DEFAULT_WEBVIEW_WIDTH_FRACTION, MIN_WEBVIEW_WIDTH, } from "./constants/layout"; -import { MOCK_FILES } from "./constants/mockFiles"; import { isExecutableLanguage } from "./utils/languageUtils"; import { useResizablePanel } from "./hooks/useResizablePanel"; import { useCollaborationSession } from "./hooks/useCollaborationSession"; @@ -35,6 +35,7 @@ import Sidebar from "./components/Sidebar"; import MainEditorArea from "./components/MainEditorArea"; import { useFileStore } from "./store/useFileStore"; import { Analytics } from "@vercel/analytics/react"; +import type { CatalogFile } from "./types/editor"; // Interface for Monaco's find controller interface FindControllerInterface extends editor.IEditorContribution { @@ -57,6 +58,26 @@ interface FindControllerInterface extends editor.IEditorContribution { closeFindWidget(): void; } +interface DefaultFileResponse { + id?: string; + name: string; + language?: string; + content: string; +} + +const inferLanguageFromFileName = (fileName: string): EditorLanguageKey => { + const normalized = fileName.toLowerCase(); + if (normalized.endsWith(".html")) return "html"; + if (normalized.endsWith(".css")) return "css"; + if (normalized.endsWith(".tsx")) return "typescript"; + if (normalized.endsWith(".ts")) return "typescript"; + if (normalized.endsWith(".jsx")) return "javascript"; + if (normalized.endsWith(".js")) return "javascript"; + if (normalized.endsWith(".json")) return "json"; + if (normalized.endsWith(".md")) return "markdown"; + return "plaintext"; +}; + const App = () => { // REFS const terminalRef = useRef(null); @@ -65,6 +86,7 @@ const App = () => { const mainContentRef = useRef(null); const tabContainerRef = useRef(null); const editorInstanceRef = useRef(null); + const defaultFilesFetchAttempted = useRef(false); // STATE const [activeIcon, setActiveIcon] = useState(null); @@ -72,11 +94,15 @@ const App = () => { const { openFiles, activeFileId } = useFileStore(); const fileContents = useFileStore((state) => state.fileContents); + const filesCatalog = useFileStore((state) => state.filesCatalog); const openFile = useFileStore((state) => state.openFile); const closeFile = useFileStore((state) => state.closeFile); const switchTab = useFileStore((state) => state.switchTab); const setFileContent = useFileStore((state) => state.setFileContent); + const initializeFromCatalog = useFileStore( + (state) => state.initializeFromCatalog + ); const [isViewMenuOpen, setIsViewMenuOpen] = useState(false); @@ -607,6 +633,71 @@ const App = () => { return Array.from(uniqueUsersMap.values()); }, [remoteUsers, userId]); + useEffect(() => { + if (defaultFilesFetchAttempted.current) { + return; + } + + defaultFilesFetchAttempted.current = true; + + const loadDefaultFilesFromRepository = async () => { + try { + const response = await axios.get( + `${import.meta.env.VITE_BACKEND_URL}/api/files/defaults` + ); + const repositoryFiles = response.data; + + if (Array.isArray(repositoryFiles) && repositoryFiles.length > 0) { + const catalog = repositoryFiles.reduce( + (acc, file) => { + const fileId = file.id ?? file.name; + if (!fileId || !file.name || typeof file.content !== "string") { + return acc; + } + + const normalizedLanguage = + (file.language as EditorLanguageKey | undefined) ?? + inferLanguageFromFileName(fileId); + + acc[fileId] = { + name: file.name, + language: normalizedLanguage, + content: file.content, + }; + + return acc; + }, + {} as Record + ); + + if (Object.keys(catalog).length > 0) { + const preferredIds = [ + "index.html", + "style.css", + "script.js", + ].filter((id) => catalog[id] !== undefined); + + const fallbackOrder = repositoryFiles + .map((file) => file.id ?? file.name) + .filter((id): id is string => !!id && catalog[id] !== undefined); + + initializeFromCatalog( + catalog, + preferredIds.length > 0 ? preferredIds : fallbackOrder + ); + } + } + } catch (error) { + console.error( + "Failed to load default files from local repository:", + error + ); + } + }; + + loadDefaultFilesFromRepository(); + }, [initializeFromCatalog]); + // Handle sending chat messages const handleSendChatMessage = useCallback( (message: string) => { @@ -775,7 +866,7 @@ const App = () => { activeFileId={activeFileId} isSessionActive={isSessionActive} handleOpenFile={(fileId) => openFile(fileId, isSessionActive)} - mockFiles={MOCK_FILES} + fileCatalog={filesCatalog} onSearchChange={handleSearchChange} onReplaceChange={handleReplaceChange} onToggleSearchOption={handleToggleSearchOption} diff --git a/client/src/components/FileExplorerPanel.tsx b/client/src/components/FileExplorerPanel.tsx index 82ca491..1f41086 100644 --- a/client/src/components/FileExplorerPanel.tsx +++ b/client/src/components/FileExplorerPanel.tsx @@ -5,20 +5,18 @@ import { languageColorMap, defaultIconColor, } from "../constants/mappings"; -import { EditorLanguageKey } from "../types/editor"; -import { MockFile } from "../constants/mockFiles"; +import { CatalogFile, EditorLanguageKey } from "../types/editor"; // Props for FileExplorerPanel - initially minimal, might adjust interface FileExplorerPanelProps { - isSessionActive: boolean; handleOpenFile: (fileId: string) => void; - mockFiles: { [key: string]: MockFile }; + fileCatalog: { [key: string]: CatalogFile }; activeFileId: string | null; } const FileExplorerPanel = ({ handleOpenFile, - mockFiles, + fileCatalog, activeFileId, }: FileExplorerPanelProps) => { const [isProjectExpanded, setIsProjectExpanded] = useState(true); @@ -62,37 +60,45 @@ const FileExplorerPanel = ({
- {Object.entries(mockFiles).map(([id, file]) => { - const IconComponent = - languageIconMap[file.language as EditorLanguageKey] || VscFile; - const iconColor = - languageColorMap[file.language as EditorLanguageKey] || - defaultIconColor; - return ( -
handleOpenFile(id)} - title={file.name} - > - - + No files loaded yet. +
+ ) : ( + Object.entries(fileCatalog).map(([id, file]) => { + const IconComponent = + languageIconMap[file.language as EditorLanguageKey] || VscFile; + const iconColor = + languageColorMap[file.language as EditorLanguageKey] || + defaultIconColor; + return ( +
handleOpenFile(id)} + title={file.name} > - {file.name} - -
- ); - })} + + + {file.name} + +
+ ); + }) + )} )} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b404cb4..61b1426 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -12,8 +12,12 @@ import SearchPanel from "./SearchPanel"; import SessionParticipantsPanel from "./SessionParticipantsPanel"; import FileExplorerPanel from "./FileExplorerPanel"; import { RemoteUser, ChatMessageType } from "../types/props"; -import { JoinStateType, SearchOptions, MatchInfo } from "../types/editor"; -import { MockFile } from "../constants/mockFiles"; +import { + JoinStateType, + SearchOptions, + MatchInfo, + CatalogFile, +} from "../types/editor"; import { ICON_BAR_WIDTH, EXPLORER_HANDLE_WIDTH } from "../constants/layout"; import { COLORS } from "../constants/colors"; @@ -39,7 +43,7 @@ interface SidebarProps { isSessionActive: boolean; activeFileId: string | null; handleOpenFile: (fileId: string) => void; - mockFiles: { [key: string]: MockFile }; + fileCatalog: { [key: string]: CatalogFile }; onSearchChange: (term: string, options: SearchOptions) => void; onReplaceChange: (value: string) => void; onToggleSearchOption: (optionKey: keyof SearchOptions) => void; @@ -77,7 +81,7 @@ const Sidebar = ({ isSessionActive, activeFileId, handleOpenFile, - mockFiles, + fileCatalog, onSearchChange, onReplaceChange, onToggleSearchOption, @@ -207,9 +211,8 @@ const Sidebar = ({ }`} > diff --git a/client/src/constants/mockFiles.ts b/client/src/constants/mockFiles.ts deleted file mode 100644 index 99778a4..0000000 --- a/client/src/constants/mockFiles.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { EditorLanguageKey } from "../types/editor"; - -export interface MockFile { - name: string; - language: EditorLanguageKey; - content: string; -} - -export const MOCK_FILES: { [key: string]: MockFile } = { - "index.html": { - name: "index.html", - language: "html", - content: ` - - - - - CodeCafe Live Preview - - - -

Welcome to CodeCafe!

-

Edit index.html, style.css, and script.js to see changes live.

- - - - -`, - }, - "style.css": { - name: "style.css", - language: "css", - content: `body { - font-family: sans-serif; - background-color: #f0f0f0; - color: #333; - padding: 20px; - transition: background-color 0.3s ease; -} - -h1 { - color: #5a67d8; /* Indigo */ - text-align: center; -} - -p { - line-height: 1.6; -} - -button { - padding: 10px 15px; - font-size: 1rem; - background-color: #5a67d8; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s ease; -} - -button:hover { - background-color: #434190; -} - -/* Add a class for dark mode */ -body.dark-mode { - background-color: #2d3748; /* Gray 800 */ - color: #e2e8f0; /* Gray 200 */ -} - -body.dark-mode h1 { - color: #9f7aea; /* Purple 400 */ -} - -body.dark-mode button { - background-color: #9f7aea; -} - -body.dark-mode button:hover { - background-color: #805ad5; /* Purple 500 */ -}`, - }, - "script.js": { - name: "script.js", - language: "javascript", - content: `console.log("CodeCafe script loaded!"); - -document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('myButton'); - const body = document.body; - - if (button) { - button.addEventListener('click', () => { - alert('Button clicked!'); - // Toggle dark mode - body.classList.toggle('dark-mode'); - console.log('Dark mode toggled'); - }); - } else { - console.error('Button element not found!'); - } - - // Example: Log the current time every 5 seconds - // setInterval(() => { - // console.log(\`Current time: \${new Date().toLocaleTimeString()}\`); - // }, 5000); -}); -`, - }, -}; diff --git a/client/src/store/useFileStore.ts b/client/src/store/useFileStore.ts index aaed4df..a7bc34e 100644 --- a/client/src/store/useFileStore.ts +++ b/client/src/store/useFileStore.ts @@ -4,35 +4,60 @@ import { EditorLanguageKey, SearchOptions, MatchInfo, + CatalogFile, } from "../types/editor"; -import { MOCK_FILES } from "../constants/mockFiles"; -const initialOpenFileIds = ["index.html", "style.css", "script.js"]; +const DEFAULT_INITIAL_OPEN_FILE_IDS = [ + "index.html", + "style.css", + "script.js", +]; + +const buildOpenFileState = ( + catalog: Record, + preferredOpenIds: string[] = DEFAULT_INITIAL_OPEN_FILE_IDS +) => { + const sanitizedPreferredIds = preferredOpenIds.filter( + (id) => catalog[id] !== undefined + ); + + const fallbackPool = + sanitizedPreferredIds.length > 0 + ? sanitizedPreferredIds + : Object.keys(catalog); + + const openIds = fallbackPool.slice(0, Math.min(fallbackPool.length, 3)); + + const openFiles: OpenFile[] = openIds.map((id) => { + const fileData = catalog[id]; + if (!fileData) { + return { + id, + name: id, + language: "plaintext" as EditorLanguageKey, + }; + } + return { + id, + name: fileData.name, + language: fileData.language, + }; + }); + + const fileContents: { [id: string]: string } = {}; + openIds.forEach((id) => { + const fileData = catalog[id]; + if (fileData) { + fileContents[id] = fileData.content; + } + }); -const initialOpenFilesData = initialOpenFileIds.map((id): OpenFile => { - const fileData = MOCK_FILES[id]; - if (!fileData) { - console.error(`Initial file ${id} not found in MOCK_FILES!`); - return { id, name: "Error", language: "plaintext" as EditorLanguageKey }; - } return { - id: id, - name: fileData.name, - language: fileData.language as EditorLanguageKey, + openFiles, + fileContents, + activeFileId: openFiles[0]?.id ?? null, }; -}); - -const initialFileContents: { [id: string]: string } = {}; -initialOpenFileIds.forEach((id) => { - const fileData = MOCK_FILES[id]; - if (fileData) { - initialFileContents[id] = fileData.content; - } else { - initialFileContents[id] = `// Error: Content for ${id} not found`; - } -}); - -const initialActiveFileId = initialOpenFileIds[0] || null; +}; const initialSearchOptions: SearchOptions = { matchCase: false, @@ -41,7 +66,10 @@ const initialSearchOptions: SearchOptions = { preserveCase: false, }; +const initialDerivedState = buildOpenFileState({}); + interface FileState { + filesCatalog: { [id: string]: CatalogFile }; openFiles: OpenFile[]; activeFileId: string | null; fileContents: { [id: string]: string }; @@ -67,6 +95,10 @@ interface FileActions { openFile: (fileId: string, isSessionActive: boolean) => void; closeFile: (fileIdToClose: string) => void; switchTab: (fileId: string) => void; + initializeFromCatalog: ( + catalog: { [id: string]: CatalogFile }, + preferredOpenIds?: string[] + ) => void; // Search Actions setSearchTerm: (term: string) => void; @@ -77,9 +109,10 @@ interface FileActions { } export const useFileStore = create((set, get) => ({ - openFiles: initialOpenFilesData, - activeFileId: initialActiveFileId, - fileContents: initialFileContents, + filesCatalog: {}, + openFiles: initialDerivedState.openFiles, + activeFileId: initialDerivedState.activeFileId, + fileContents: initialDerivedState.fileContents, draggingId: null, dropIndicator: { tabId: null, side: null }, @@ -106,20 +139,19 @@ export const useFileStore = create((set, get) => ({ }, openFile: (fileId) => { - const fileData = MOCK_FILES[fileId]; + const state = get(); + const fileData = state.filesCatalog[fileId]; if (!fileData) { - console.error(`Cannot open file: ${fileId} not found in MOCK_FILES.`); + console.error(`Cannot open file: ${fileId} not found in catalog.`); return; } - - const state = get(); const fileAlreadyOpen = state.openFiles.some((f) => f.id === fileId); if (!fileAlreadyOpen) { const newOpenFile: OpenFile = { id: fileId, name: fileData.name, - language: fileData.language as EditorLanguageKey, + language: fileData.language, }; const newStateUpdate: Partial = { @@ -194,4 +226,24 @@ export const useFileStore = create((set, get) => ({ matchInfo: null, // searchOptions are intentionally not reset here, user might want to keep them }), + initializeFromCatalog: (catalog, preferredOpenIds) => { + const derivedState = buildOpenFileState( + catalog, + preferredOpenIds && preferredOpenIds.length > 0 + ? preferredOpenIds + : DEFAULT_INITIAL_OPEN_FILE_IDS + ); + + set({ + filesCatalog: catalog, + openFiles: derivedState.openFiles, + activeFileId: derivedState.activeFileId, + fileContents: derivedState.fileContents, + draggingId: null, + dropIndicator: { tabId: null, side: null }, + searchTerm: "", + replaceTerm: "", + matchInfo: null, + }); + }, })); diff --git a/client/src/types/editor.ts b/client/src/types/editor.ts index d486056..92bf424 100644 --- a/client/src/types/editor.ts +++ b/client/src/types/editor.ts @@ -38,6 +38,12 @@ export interface OpenFile { language: EditorLanguageKey; } +export interface CatalogFile { + name: string; + language: EditorLanguageKey; + content: string; +} + export interface TerminalHandle { writeToTerminal: (output: string) => void; clear: () => void; diff --git a/default-files/index.html b/default-files/index.html new file mode 100644 index 0000000..961cbf5 --- /dev/null +++ b/default-files/index.html @@ -0,0 +1,16 @@ + + + + + + CodeCafe Live Preview + + + +

Welcome to CodeCafe! 23

+

Edit index.html, style.css, and script.js to see changes live.

+ + + + + diff --git a/default-files/script.js b/default-files/script.js new file mode 100644 index 0000000..b399410 --- /dev/null +++ b/default-files/script.js @@ -0,0 +1,16 @@ +console.log("CodeCafe script loaded!"); + +document.addEventListener("DOMContentLoaded", () => { + const button = document.getElementById("myButton"); + const body = document.body; + + if (button) { + button.addEventListener("click", () => { + alert("Button clicked!"); + body.classList.toggle("dark-mode"); + console.log("Dark mode toggled"); + }); + } else { + console.error("Button element not found!"); + } +}); diff --git a/default-files/style.css b/default-files/style.css new file mode 100644 index 0000000..8ffc827 --- /dev/null +++ b/default-files/style.css @@ -0,0 +1,48 @@ +body { + font-family: sans-serif; + background-color: #f0f0f0; + color: #333; + padding: 20px; + transition: background-color 0.3s ease; +} + +h1 { + color: #5a67d8; + text-align: center; +} + +p { + line-height: 1.6; +} + +button { + padding: 10px 15px; + font-size: 1rem; + background-color: #5a67d8; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover { + background-color: #434190; +} + +body.dark-mode { + background-color: #2d3748; + color: #e2e8f0; +} + +body.dark-mode h1 { + color: #9f7aea; +} + +body.dark-mode button { + background-color: #9f7aea; +} + +body.dark-mode button:hover { + background-color: #805ad5; +} diff --git a/docker-compose.yml b/docker-compose.yml index d35a2bc..e88e298 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: - SPRING_REDIS_PORT=6379 # - SPRING_REDIS_PASSWORD=yourpassword # - SPRING_REDIS_SSL_ENABLED=false + - CODECAFE_DEFAULT_FILES_REPOSITORY_PATH=/app/default-files + volumes: + - ./default-files:/app/default-files:ro depends_on: - redis networks: diff --git a/server/src/main/java/com/codecafe/backend/config/WebConfig.java b/server/src/main/java/com/codecafe/backend/config/WebConfig.java index 47a21cd..dc781a6 100644 --- a/server/src/main/java/com/codecafe/backend/config/WebConfig.java +++ b/server/src/main/java/com/codecafe/backend/config/WebConfig.java @@ -1,18 +1,46 @@ package com.codecafe.backend.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { + private static final List ALLOWED_ORIGINS = List.of( + "https://codecafe.app", + "http://localhost:5173", + "http://localhost:8880", + "http://127.0.0.1:8880" + ); + @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/api/**") - .allowedOrigins("https://codecafe.app", "http://localhost:5173", "http://localhost") - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true) // Allow credentials (e.g., cookies) - .maxAge(3600); // Cache preflight response for 1 hour + registry.addMapping("/api/**") + .allowedOrigins(ALLOWED_ORIGINS.toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Bean + public CorsFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(ALLOWED_ORIGINS); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + source.registerCorsConfiguration("/ws/**", config); + return new CorsFilter(source); } } diff --git a/server/src/main/java/com/codecafe/backend/controller/DefaultFileController.java b/server/src/main/java/com/codecafe/backend/controller/DefaultFileController.java new file mode 100644 index 0000000..b52f0dc --- /dev/null +++ b/server/src/main/java/com/codecafe/backend/controller/DefaultFileController.java @@ -0,0 +1,36 @@ +package com.codecafe.backend.controller; + +import com.codecafe.backend.dto.DefaultFileDTO; +import com.codecafe.backend.service.DefaultFileService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; + +@RestController +@RequestMapping("/api/files") +public class DefaultFileController { + + private static final Logger log = LoggerFactory.getLogger(DefaultFileController.class); + private final DefaultFileService defaultFileService; + + public DefaultFileController(DefaultFileService defaultFileService) { + this.defaultFileService = defaultFileService; + } + + @GetMapping("/defaults") + public ResponseEntity> getDefaultFiles() { + try { + List files = defaultFileService.loadDefaultFiles(); + return ResponseEntity.ok(files); + } catch (IllegalStateException ex) { + log.warn("Default files are unavailable: {}", ex.getMessage()); + return ResponseEntity.ok(Collections.emptyList()); + } + } +} diff --git a/server/src/main/java/com/codecafe/backend/dto/DefaultFileDTO.java b/server/src/main/java/com/codecafe/backend/dto/DefaultFileDTO.java new file mode 100644 index 0000000..7bd3e26 --- /dev/null +++ b/server/src/main/java/com/codecafe/backend/dto/DefaultFileDTO.java @@ -0,0 +1,54 @@ +package com.codecafe.backend.dto; + +/** + * Simple DTO representing a default file served from the local repository. + */ +public class DefaultFileDTO { + private String id; + private String name; + private String language; + private String content; + + public DefaultFileDTO() { + // Default constructor for Jackson + } + + public DefaultFileDTO(String id, String name, String language, String content) { + this.id = id; + this.name = name; + this.language = language; + this.content = content; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/server/src/main/java/com/codecafe/backend/service/DefaultFileService.java b/server/src/main/java/com/codecafe/backend/service/DefaultFileService.java new file mode 100644 index 0000000..81affb5 --- /dev/null +++ b/server/src/main/java/com/codecafe/backend/service/DefaultFileService.java @@ -0,0 +1,123 @@ +package com.codecafe.backend.service; + +import com.codecafe.backend.dto.DefaultFileDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * Service that loads the default project files from a local repository directory. + */ +@Service +public class DefaultFileService { + + private static final Logger log = LoggerFactory.getLogger(DefaultFileService.class); + private final Path repositoryRoot; + + public DefaultFileService( + @Value("${codecafe.default-files.repository-path:${CODECAFE_DEFAULT_FILES_REPOSITORY_PATH:}}") + String repositoryPath) { + if (repositoryPath == null || repositoryPath.isBlank()) { + this.repositoryRoot = null; + log.warn("No repository path configured for default files. Falling back to in-app defaults."); + } else { + this.repositoryRoot = Paths.get(repositoryPath).toAbsolutePath().normalize(); + log.info("Default files repository path set to: {}", this.repositoryRoot); + } + } + + public List loadDefaultFiles() { + if (repositoryRoot == null) { + throw new IllegalStateException("Default files repository path is not configured."); + } + + if (!Files.exists(repositoryRoot) || !Files.isDirectory(repositoryRoot)) { + throw new IllegalStateException( + String.format("Default files repository path does not exist or is not a directory: %s", repositoryRoot) + ); + } + + List files = new ArrayList<>(); + try (Stream stream = Files.list(repositoryRoot)) { + stream + .filter(Files::isRegularFile) + .map(this::mapToDefaultFile) + .filter(Objects::nonNull) + .forEach(files::add); + } catch (IOException ioException) { + throw new IllegalStateException( + String.format("Failed to read default files from %s", repositoryRoot), + ioException + ); + } + + return files; + } + + private DefaultFileDTO mapToDefaultFile(Path filePath) { + try { + String relativeName = repositoryRoot.relativize(filePath).toString().replace('\\', '/'); + String content = Files.readString(filePath); + String language = inferLanguage(relativeName); + return new DefaultFileDTO(relativeName, relativeName, language, content); + } catch (IOException ioException) { + log.warn("Skipping default file {} due to read error: {}", filePath, ioException.getMessage()); + return null; + } + } + + private String inferLanguage(String fileName) { + String normalized = fileName.toLowerCase(Locale.ROOT); + if (normalized.endsWith(".html")) { + return "html"; + } + if (normalized.endsWith(".css")) { + return "css"; + } + if (normalized.endsWith(".tsx") || normalized.endsWith(".ts")) { + return "typescript"; + } + if (normalized.endsWith(".jsx") || normalized.endsWith(".js")) { + return "javascript"; + } + if (normalized.endsWith(".json")) { + return "json"; + } + if (normalized.endsWith(".md")) { + return "markdown"; + } + if (normalized.endsWith(".py")) { + return "python"; + } + if (normalized.endsWith(".java")) { + return "java"; + } + if (normalized.endsWith(".go")) { + return "go"; + } + if (normalized.endsWith(".rs")) { + return "rust"; + } + if (normalized.endsWith(".rb")) { + return "ruby"; + } + if (normalized.endsWith(".c")) { + return "c"; + } + if (normalized.endsWith(".cpp") || normalized.endsWith(".cc") || normalized.endsWith(".cxx")) { + return "cplusplus"; + } + return "plaintext"; + } +} diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 015cdbc..f492925 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -51,3 +51,7 @@ management.endpoints.web.exposure.include=health,info management.endpoint.health.show-details=always # To see all health indicators in the response, including Redis: management.endpoint.health.show-components=always + +# Configure the optional local repository that provides default editor files +# codecafe.default-files.repository-path=/absolute/path/to/your/default/project +# or export CODECAFE_DEFAULT_FILES_REPOSITORY_PATH for containerized deployments diff --git a/server/src/test/java/com/codecafe/backend/controller/DefaultFileControllerTest.java b/server/src/test/java/com/codecafe/backend/controller/DefaultFileControllerTest.java new file mode 100644 index 0000000..c77dc84 --- /dev/null +++ b/server/src/test/java/com/codecafe/backend/controller/DefaultFileControllerTest.java @@ -0,0 +1,58 @@ +package com.codecafe.backend.controller; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codecafe.backend.dto.DefaultFileDTO; +import com.codecafe.backend.service.DefaultFileService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = DefaultFileController.class) +class DefaultFileControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private DefaultFileService defaultFileService; + + @Test + @DisplayName("GET /api/files/defaults returns repository files") + void getDefaultFilesReturnsRepositoryFiles() throws Exception { + List files = List.of( + new DefaultFileDTO("index.html", "index.html", "html", ""), + new DefaultFileDTO("style.css", "style.css", "css", "body{}")); + when(defaultFileService.loadDefaultFiles()).thenReturn(files); + + mockMvc.perform(get("/api/files/defaults")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].id", equalTo("index.html"))) + .andExpect(jsonPath("$[1].id", equalTo("style.css"))); + } + + @Test + @DisplayName("GET /api/files/defaults returns empty list when repository unavailable") + void getDefaultFilesReturnsEmptyListWhenUnavailable() throws Exception { + when(defaultFileService.loadDefaultFiles()) + .thenThrow(new IllegalStateException("not configured")); + + mockMvc.perform(get("/api/files/defaults")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(0))); + } +} \ No newline at end of file diff --git a/server/src/test/java/com/codecafe/backend/service/DefaultFileServiceTest.java b/server/src/test/java/com/codecafe/backend/service/DefaultFileServiceTest.java new file mode 100644 index 0000000..d86b61e --- /dev/null +++ b/server/src/test/java/com/codecafe/backend/service/DefaultFileServiceTest.java @@ -0,0 +1,75 @@ +package com.codecafe.backend.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.codecafe.backend.dto.DefaultFileDTO; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DefaultFileServiceTest { + + @TempDir + Path tempDir; + + private Path htmlFile; + private Path cssFile; + + @BeforeEach + void setUp() throws IOException { + htmlFile = tempDir.resolve("index.html"); + Files.writeString(htmlFile, ""); + + cssFile = tempDir.resolve("style.css"); + Files.writeString(cssFile, "body { color: #000; }"); + + Files.createDirectory(tempDir.resolve("nested")); + } + + @Test + @DisplayName("loadDefaultFiles returns files with inferred languages") + void loadDefaultFilesReturnsFiles() { + DefaultFileService service = new DefaultFileService(tempDir.toString()); + + List files = service.loadDefaultFiles(); + + assertThat(files) + .hasSize(2) + .extracting(DefaultFileDTO::getId, DefaultFileDTO::getLanguage) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("index.html", "html"), + org.assertj.core.groups.Tuple.tuple("style.css", "css")); + + assertThat(files) + .extracting(DefaultFileDTO::getContent) + .contains("", "body { color: #000; }"); + } + + @Test + @DisplayName("loadDefaultFiles throws when repository path is not configured") + void loadDefaultFilesThrowsWhenPathMissing() { + DefaultFileService service = new DefaultFileService(""); + + IllegalStateException exception = assertThrows(IllegalStateException.class, service::loadDefaultFiles); + + assertThat(exception.getMessage()) + .contains("Default files repository path is not configured"); + } + + @Test + @DisplayName("loadDefaultFiles throws when repository path is invalid") + void loadDefaultFilesThrowsWhenPathInvalid() { + DefaultFileService service = new DefaultFileService(tempDir.resolve("missing").toString()); + + IllegalStateException exception = assertThrows(IllegalStateException.class, service::loadDefaultFiles); + + assertThat(exception.getMessage()) + .contains("does not exist or is not a directory"); + } +} \ No newline at end of file