diff --git a/.cursorrules b/.cursorrules new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.windsurfrules b/.windsurfrules new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2540536 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Agentic Workflow Instructions + +This document outlines the standard operating procedures for building features and contributing to the Turtorial codebase. + +## 1. Test-Driven Development (TDD) + +**Always write failing tests first.** + +* **Backend (Java/Spring Boot):** + * Use JUnit 5 for unit and integration tests. + * Ensure new services and components are tested in isolation where possible. + * Run tests via `./mvnw test`. + +* **Frontend (React/TypeScript):** + * Use Vitest for unit testing logic and components. + * Use Playwright for end-to-end and component testing. + * Ensure new components have accompanying tests. + * Run tests via `npm run test` (Vitest) or `npm run e2e` (Playwright). + +## 2. Frontend Component Architecture + +**Keep components independent, decoupled, and testable.** + +* **Storybook:** Develop and test UI components in isolation using Storybook (`npm run storybook`). +* **Decoupling:** Avoid tight coupling between components. Use props and composition to maximize reusability. +* **State Management:** Keep state local where possible, or lift it up responsibly. Avoid global state pollution. + +## 3. Feature Independence & Decoupling + +* **Modularity:** Design features to be modular and self-contained. +* **Separation of Concerns:** Clearly separate business logic from presentation and data access. +* **Interfaces:** Use interfaces to define contracts between modules, facilitating easier testing and refactoring. + +## 4. Documentation & Lesson Updates + +**When adding a new feature, you MUST document it for users.** + +* **Target Audience:** Lesson authors (users writing tutorials). +* **Location:** The default lesson included on the classpath (`src/main/resources/lessons/introduction`). +* **Requirement:** + * Add a new step or section to the default lesson explaining how the feature works and how to use it. + * Provide clear examples and code snippets. + * Ensure the documentation is visible immediately when a user starts the application. +* **Why:** This ensures that new capabilities are immediately discoverable and usable by the community. + +## 5. Source of Truth + +This `AGENTS.md` file is the single source of truth for agent instructions. It is symlinked to various agent-specific configuration files (e.g., `CLAUDE.md`, `GEMINI.md`, `.cursorrules`, etc.) to ensure consistency across different AI assistants. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 0dfaf23..5664832 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ Open your browser to `http://localhost:8080` to see your lesson in action! We love contributions! If you're a developer looking to improve the platform itself (the Java backend or React frontend), check out [BUILDING.md](BUILDING.md) for technical build instructions. +## 🤖 Agentic Workflow + +If you are an AI agent or using AI tools to contribute to this project, please refer to [AGENTS.md](AGENTS.md) for specific instructions and guidelines. This file serves as the source of truth for all agentic workflows. + ## 📄 License [MIT License](LICENSE) diff --git a/src/main/frontend/package-lock.json b/src/main/frontend/package-lock.json index 5b5b853..7caa81b 100644 --- a/src/main/frontend/package-lock.json +++ b/src/main/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", @@ -1211,6 +1212,29 @@ "react": ">=16" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@neoconfetti/react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", @@ -4003,6 +4027,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5354,6 +5386,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6890,6 +6932,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdast-util-directive": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", @@ -7818,6 +7873,17 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -8908,6 +8974,12 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", diff --git a/src/main/frontend/package.json b/src/main/frontend/package.json index b752c70..3d74d62 100644 --- a/src/main/frontend/package.json +++ b/src/main/frontend/package.json @@ -15,6 +15,7 @@ "test-storybook": "vitest --project=storybook" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.8", diff --git a/src/main/frontend/src/components/MarkdownRenderer.tsx b/src/main/frontend/src/components/MarkdownRenderer.tsx index 62c25a1..3ec2157 100644 --- a/src/main/frontend/src/components/MarkdownRenderer.tsx +++ b/src/main/frontend/src/components/MarkdownRenderer.tsx @@ -4,6 +4,7 @@ import rehypeHighlight from 'rehype-highlight'; import remarkGfm from 'remark-gfm'; import remarkDirective from 'remark-directive'; import { visit } from 'unist-util-visit'; +import { FileCode } from 'lucide-react'; import 'highlight.js/styles/github-dark.css'; import CodeBlock from './lesson/CodeBlock'; @@ -30,6 +31,18 @@ function remarkAdmonitions() { }; } } + if (node.type === 'leafDirective') { + if (node.name === 'editor') { + const data = node.data || (node.data = {}); + const tagName = 'div'; + data.hName = tagName; + data.hProperties = { + className: "editor-directive", + "data-admonition-type": "editor", + "data-admonition-title": (node.attributes && node.attributes.title) ? node.attributes.title : undefined, + }; + } + } }); }; } @@ -72,10 +85,26 @@ const COMPONENTS: React.ComponentProps['components'] = { // Custom rendering for directives (Admonitions) div: ({ node, className, children, ...props }) => { const type = props['data-admonition-type' as keyof typeof props] as string; + + if (type === 'editor') { + const file = props['data-admonition-title' as keyof typeof props] as string; + return ( +
+ +
+ ); + } + if (type) { const title = props['data-admonition-title' as keyof typeof props] as string; return ( - // Cast type to AdmonitionType because we know it's one of the allowed strings if logic allows {children} @@ -140,7 +169,10 @@ const COMPONENTS: React.ComponentProps['components'] = { export default function MarkdownRenderer({ content }: MarkdownRendererProps) { // Pre-process content to support ":::note Title" syntax by converting it to ":::note{title="Title"}" const normalizedContent = useMemo(() => { - return content.replace(/^:::(note|tip|warning|important|caution)\s+(.+)$/gm, ':::$1{title="$2"}'); + let newContent = content.replace(/^:::(note|tip|warning|important|caution)\s+(.+)$/gm, ':::$1{title="$2"}'); + // Convert :::editor filename to ::editor{title="filename"} (leaf directive) + newContent = newContent.replace(/^:::editor\s+(.+)$/gm, '::editor{title="$1"}'); + return newContent; }, [content]); return ( diff --git a/src/main/frontend/src/components/lesson/EditorPanel.tsx b/src/main/frontend/src/components/lesson/EditorPanel.tsx new file mode 100644 index 0000000..0b0d1eb --- /dev/null +++ b/src/main/frontend/src/components/lesson/EditorPanel.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import Editor, { type OnMount } from '@monaco-editor/react'; +import { Save, Loader2 } from 'lucide-react'; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +interface EditorPanelProps { + initialPath?: string; +} + +export default function EditorPanel({ initialPath }: EditorPanelProps) { + const [path, setPath] = useState(initialPath || ''); + const [content, setContent] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const editorRef = useRef(null); + + // Effect to update path if prop changes (though usually initialPath is static for a tab instance) + useEffect(() => { + if (initialPath) { + setPath(initialPath); + } + }, [initialPath]); + + useEffect(() => { + if (!path) return; + + const fetchContent = async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); + if (response.status === 404) { + // File doesn't exist, start empty or handle as new file + // For now, treat as empty new file but warn + // setContent(''); + // actually if 404, maybe we should let user create it on save. + // But if it's "edit specific file", it might be expected to exist. + // Let's assume it might not exist yet. + console.log('File not found, starting empty'); + } else if (!response.ok) { + throw new Error(`Failed to load file: ${response.statusText}`); + } else { + const text = await response.text(); + setContent(text); + } + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + fetchContent(); + }, [path]); + + const handleSave = useCallback(async () => { + if (!path) return; + setIsSaving(true); + setError(null); + try { + const currentContent = editorRef.current?.getValue() || content; + const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: currentContent, + }); + + if (!response.ok) { + throw new Error(`Failed to save file: ${response.statusText}`); + } + console.log('File saved successfully'); + } catch (err: any) { + setError(err.message); + } finally { + setIsSaving(false); + } + }, [path, content]); + + const handleEditorDidMount: OnMount = (editor, monaco) => { + editorRef.current = editor; + + // Add Ctrl+S / Cmd+S keybinding + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + handleSave(); + }); + }; + + // Determine language based on extension + const getLanguage = (filePath: string) => { + const ext = filePath.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'js': return 'javascript'; + case 'ts': return 'typescript'; + case 'jsx': return 'javascript'; + case 'tsx': return 'typescript'; + case 'html': return 'html'; + case 'css': return 'css'; + case 'json': return 'json'; + case 'md': return 'markdown'; + case 'py': return 'python'; + case 'java': return 'java'; + case 'c': return 'c'; + case 'cpp': return 'cpp'; + case 'sh': return 'shell'; + case 'yaml': case 'yml': return 'yaml'; + case 'xml': return 'xml'; + case 'sql': return 'sql'; + default: return 'plaintext'; + } + }; + + return ( +
+
+
+ {path || 'Untitled'} + {isLoading && } +
+
+ {error && {error}} + +
+
+
+ setContent(value || '')} + onMount={handleEditorDidMount} + options={{ + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + automaticLayout: true, + wordWrap: 'on', + padding: { top: 10 }, + }} + /> +
+
+ ); +} diff --git a/src/main/frontend/src/components/lesson/TabbedPane.tsx b/src/main/frontend/src/components/lesson/TabbedPane.tsx index 8ea1a17..b9d489b 100644 --- a/src/main/frontend/src/components/lesson/TabbedPane.tsx +++ b/src/main/frontend/src/components/lesson/TabbedPane.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import { Terminal, Plus, X, Globe } from 'lucide-react'; +import { Terminal, Plus, X, Globe, FileCode } from 'lucide-react'; import TerminalPanel from './TerminalPanel'; import BrowserPanel from './BrowserPanel'; +import EditorPanel from './EditorPanel'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -13,7 +14,7 @@ function cn(...inputs: ClassValue[]) { interface Tab { id: string; - type: 'terminal' | 'browser'; + type: 'terminal' | 'browser' | 'editor'; title: string; url?: string; } @@ -34,14 +35,17 @@ export default function TabbedPane({ initialTabs }: TabbedPaneProps = {}) { const tabsRef = useRef(tabs); const activeTabIdRef = useRef(activeTabId); + const addTab = useCallback((type: 'terminal' | 'browser' | 'editor', url?: string) => { + const id = `${type}-${Date.now()}`; + let title = ''; + if (type === 'terminal') title = 'Terminal'; + else if (type === 'browser') title = 'Browser'; + else if (type === 'editor') title = url ? url.split('/').pop() || 'Editor' : 'Editor'; - - const addTab = useCallback((type: 'terminal' | 'browser', url?: string) => { - const id = `${type === 'terminal' ? 'term' : 'browser'}-${Date.now()}`; const newTab: Tab = { id, type, - title: type === 'terminal' ? 'Terminal' : 'Browser', + title, url }; setTabs(prev => { @@ -76,6 +80,22 @@ export default function TabbedPane({ initialTabs }: TabbedPaneProps = {}) { addTab('browser', customEvent.detail); }, [addTab]); + const handleEditorOpen = useCallback((e: Event) => { + e.stopImmediatePropagation(); + const customEvent = e as CustomEvent; + const filePath = customEvent.detail; + + // Check if tab already exists for this file + const currentTabs = tabsRef.current; + const existingTab = currentTabs.find(t => t.type === 'editor' && t.url === filePath); + + if (existingTab) { + setActiveTabId(existingTab.id); + } else { + addTab('editor', filePath); + } + }, [addTab]); + const handleRunCommand = useCallback((e: Event) => { e.stopImmediatePropagation(); const customEvent = e as CustomEvent; @@ -115,12 +135,14 @@ export default function TabbedPane({ initialTabs }: TabbedPaneProps = {}) { useEffect(() => { window.addEventListener('browser:open', handleBrowserOpen); window.addEventListener('terminal:run-command', handleRunCommand); + window.addEventListener('editor:open', handleEditorOpen); return () => { window.removeEventListener('browser:open', handleBrowserOpen); window.removeEventListener('terminal:run-command', handleRunCommand); + window.removeEventListener('editor:open', handleEditorOpen); }; - }, [handleBrowserOpen, handleRunCommand]); + }, [handleBrowserOpen, handleRunCommand, handleEditorOpen]); return (
- {tab.type === 'terminal' ? : } + {tab.type === 'terminal' ? : + tab.type === 'browser' ? : + } {tab.title}
{tab.type === 'terminal' && addTab('browser', url)} />} {tab.type === 'browser' && } + {tab.type === 'editor' && } ))}
diff --git a/src/main/java/com/snowfort/turtorial/controller/FileController.java b/src/main/java/com/snowfort/turtorial/controller/FileController.java new file mode 100644 index 0000000..83dfab1 --- /dev/null +++ b/src/main/java/com/snowfort/turtorial/controller/FileController.java @@ -0,0 +1,63 @@ +package com.snowfort.turtorial.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@RestController +@RequestMapping("/api/files") +public class FileController { + + private final Path workingDir = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize(); + + @GetMapping("/content") + public ResponseEntity getFileContent(@RequestParam String path) { + try { + // Prevent path traversal + if (path.contains("..")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Access denied: Path traversal detected"); + } + + Path filePath = workingDir.resolve(path).normalize(); + if (!filePath.startsWith(workingDir)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Access denied"); + } + if (!Files.exists(filePath)) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("File not found"); + } + String content = Files.readString(filePath, StandardCharsets.UTF_8); + return ResponseEntity.ok(content); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error reading file: " + e.getMessage()); + } + } + + @PostMapping("/content") + public ResponseEntity saveFileContent(@RequestParam String path, @RequestBody String content) { + try { + // Prevent path traversal + if (path.contains("..")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Access denied: Path traversal detected"); + } + + Path filePath = workingDir.resolve(path).normalize(); + if (!filePath.startsWith(workingDir)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Access denied"); + } + + if (filePath.getParent() != null) { + Files.createDirectories(filePath.getParent()); + } + Files.writeString(filePath, content, StandardCharsets.UTF_8); + return ResponseEntity.ok("File saved"); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error writing file: " + e.getMessage()); + } + } +} diff --git a/src/main/resources/lessons/introduction/09-code-editor.mdx b/src/main/resources/lessons/introduction/09-code-editor.mdx new file mode 100644 index 0000000..db254a7 --- /dev/null +++ b/src/main/resources/lessons/introduction/09-code-editor.mdx @@ -0,0 +1,42 @@ +--- +title: "The Code Editor" +description: "Learn how to use the built-in Monaco code editor in your tutorials." +section: "Features" +order: 9 +--- + +# The Code Editor + +Turtorial includes a powerful, web-based code editor (based on Monaco, the engine behind VS Code) that you can embed directly into your lessons. This allows learners to view and edit files without leaving the browser environment. + +## Opening a File + +To open a file in the editor, use the `:::editor` directive in your Markdown content. This renders a button that, when clicked, opens the specified file in a new tab. + +### Syntax + +```markdown +:::editor filename +``` + +### Example + +Click the button below to open a sample file (if it exists in the working directory): + +:::editor README.md + +## How it Works + +1. **Directive:** When you use `:::editor path/to/file.txt`, Turtorial renders a button labeled `Open path/to/file.txt in editor`. +2. **Action:** Clicking the button opens a new tab in the right-hand pane. +3. **Content:** The editor fetches the content of the file from the server. +4. **Saving:** You can save changes by pressing `Ctrl+S` (or `Cmd+S` on macOS) or clicking the **Save** button in the editor toolbar. + +## Best Practices + +* **File Existence:** Ensure the file you are linking to exists in the working directory or is created by a previous step (e.g., using a `runCommand` or a `cat` block). +* **Pathing:** Paths are relative to the application's working directory. Use relative paths carefully. +* **Context:** Explain *why* the user needs to edit the file and *what* changes they should make. + +> [!TIP] +> You can combine the editor with `runCommand` steps. For example, have the user edit a configuration file, then provide a run button to restart a service and apply the changes.