feat(ui): add Docker Compose file editor autocomplete

- Implement Docker Compose file autocompletion in CodeMirror
- Add comprehensive suggestions for top-level and service-level keys
- Include a JSON schema for Docker Compose file validation
- Enhance code editor with intelligent YAML editing support
This commit is contained in:
Mauricio Siu 2025-03-09 13:00:22 -06:00
parent 6287f3be4a
commit 64643c11aa
5 changed files with 1244 additions and 35 deletions

View File

@ -97,6 +97,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto"> <div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
<CodeEditor <CodeEditor
// disabled // disabled
language="yaml"
value={field.value} value={field.value}
className="font-mono" className="font-mono"
wrapperClassName="compose-file-editor" wrapperClassName="compose-file-editor"

View File

@ -9,6 +9,117 @@ import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import {
autocompletion,
type CompletionContext,
type CompletionResult,
type Completion,
} from "@codemirror/autocomplete";
// Docker Compose completion options
const dockerComposeServices = [
{ label: "services", type: "keyword", info: "Define services" },
{ label: "version", type: "keyword", info: "Specify compose file version" },
{ label: "volumes", type: "keyword", info: "Define volumes" },
{ label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" },
].map((opt) => ({
...opt,
apply: (view: EditorView, completion: Completion) => {
const insert = `${completion.label}:`;
view.dispatch({
changes: {
from: view.state.selection.main.from,
to: view.state.selection.main.to,
insert,
},
selection: { anchor: view.state.selection.main.from + insert.length },
});
},
}));
const dockerComposeServiceOptions = [
{
label: "image",
type: "keyword",
info: "Specify the image to start the container from",
},
{ label: "build", type: "keyword", info: "Build configuration" },
{ label: "command", type: "keyword", info: "Override the default command" },
{ label: "container_name", type: "keyword", info: "Custom container name" },
{
label: "depends_on",
type: "keyword",
info: "Express dependency between services",
},
{ label: "environment", type: "keyword", info: "Add environment variables" },
{
label: "env_file",
type: "keyword",
info: "Add environment variables from a file",
},
{
label: "expose",
type: "keyword",
info: "Expose ports without publishing them",
},
{ label: "ports", type: "keyword", info: "Expose ports" },
{
label: "volumes",
type: "keyword",
info: "Mount host paths or named volumes",
},
{ label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" },
].map((opt) => ({
...opt,
apply: (view: EditorView, completion: Completion) => {
const insert = `${completion.label}:`;
view.dispatch({
changes: {
from: view.state.selection.main.from,
to: view.state.selection.main.to,
insert,
},
selection: { anchor: view.state.selection.main.from + insert.length },
});
},
}));
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
const word = context.matchBefore(/\w*/);
if (!word) return null;
if (!word.text && !context.explicit) return null;
// Check if we're at the root level
const line = context.state.doc.lineAt(context.pos);
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
console.log(indentation);
if (indentation === 0) {
return {
from: word.from,
options: dockerComposeServices,
validFor: /^\w*$/,
};
}
// If we're inside a service definition
if (indentation === 4) {
return {
from: word.from,
options: dockerComposeServiceOptions,
validFor: /^\w*$/,
};
}
return null;
}
interface Props extends ReactCodeMirrorProps { interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string; wrapperClassName?: string;
disabled?: boolean; disabled?: boolean;
@ -45,6 +156,11 @@ export const CodeEditor = ({
? StreamLanguage.define(shell) ? StreamLanguage.define(shell)
: StreamLanguage.define(properties), : StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [], props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
? autocompletion({
override: [dockerComposeComplete],
})
: [],
]} ]}
{...props} {...props}
editable={!props.disabled} editable={!props.disabled}

File diff suppressed because it is too large Load Diff

View File

@ -36,8 +36,6 @@
"test": "vitest --config __test__/vitest.config.ts" "test": "vitest --config __test__/vitest.config.ts"
}, },
"dependencies": { "dependencies": {
"micromatch": "4.0.8",
"ai": "^4.0.23",
"@ai-sdk/anthropic": "^1.0.6", "@ai-sdk/anthropic": "^1.0.6",
"@ai-sdk/azure": "^1.0.15", "@ai-sdk/azure": "^1.0.15",
"@ai-sdk/cohere": "^1.0.6", "@ai-sdk/cohere": "^1.0.6",
@ -45,20 +43,7 @@
"@ai-sdk/mistral": "^1.0.6", "@ai-sdk/mistral": "^1.0.6",
"@ai-sdk/openai": "^1.0.12", "@ai-sdk/openai": "^1.0.12",
"@ai-sdk/openai-compatible": "^0.0.13", "@ai-sdk/openai-compatible": "^0.0.13",
"ollama-ai-provider": "^1.1.0", "@codemirror/autocomplete": "^6.18.6",
"better-auth": "1.2.0",
"bl": "6.0.11",
"rotating-file-stream": "3.2.3",
"qrcode": "^1.5.3",
"otpauth": "^9.2.3",
"hi-base32": "^0.5.1",
"boxen": "^7.1.1",
"@octokit/auth-app": "^6.0.4",
"nodemailer": "6.9.14",
"@react-email/components": "^0.0.21",
"node-os-utils": "1.3.7",
"@lucia-auth/adapter-drizzle": "1.0.7",
"dockerode": "4.0.2",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1", "@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1", "@codemirror/language": "^6.10.1",
@ -66,7 +51,10 @@
"@codemirror/view": "6.29.0", "@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*", "@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.4", "@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@lucia-auth/adapter-drizzle": "1.0.7",
"@octokit/auth-app": "^6.0.4",
"@octokit/webhooks": "^13.2.7", "@octokit/webhooks": "^13.2.7",
"@radix-ui/react-accordion": "1.1.2", "@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
@ -87,8 +75,10 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.21",
"@stepperize/react": "4.0.1", "@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0", "@stripe/stripe-js": "4.8.0",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.16.0", "@tanstack/react-table": "^8.16.0",
"@trpc/client": "^10.43.6", "@trpc/client": "^10.43.6",
@ -98,10 +88,14 @@
"@uiw/codemirror-theme-github": "^4.22.1", "@uiw/codemirror-theme-github": "^4.22.1",
"@uiw/react-codemirror": "^4.22.1", "@uiw/react-codemirror": "^4.22.1",
"@xterm/addon-attach": "0.10.0", "@xterm/addon-attach": "0.10.0",
"@xterm/xterm": "^5.4.0",
"@xterm/addon-clipboard": "0.1.0", "@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.4.0",
"adm-zip": "^0.5.14", "adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"better-auth": "1.2.0",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2", "bullmq": "5.4.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@ -109,10 +103,12 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"drizzle-orm": "^0.39.1", "drizzle-orm": "^0.39.1",
"drizzle-zod": "0.5.1", "drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3", "fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -120,15 +116,21 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"lucia": "^3.0.1", "lucia": "^3.0.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3", "nanoid": "3",
"next": "^15.0.1", "next": "^15.0.1",
"next-i18next": "^15.3.1", "next-i18next": "^15.3.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"node-os-utils": "1.3.7",
"node-pty": "1.0.0", "node-pty": "1.0.0",
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2", "octokit": "3.1.2",
"ollama-ai-provider": "^1.1.0",
"otpauth": "^9.2.3",
"postgres": "3.4.4", "postgres": "3.4.4",
"public-ip": "6.0.2", "public-ip": "6.0.2",
"qrcode": "^1.5.3",
"react": "18.2.0", "react": "18.2.0",
"react-confetti-explosion": "2.1.2", "react-confetti-explosion": "2.1.2",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
@ -137,6 +139,7 @@
"react-i18next": "^15.1.0", "react-i18next": "^15.1.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"recharts": "^2.12.7", "recharts": "^2.12.7",
"rotating-file-stream": "3.2.3",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"ssh2": "1.15.0", "ssh2": "1.15.0",
@ -150,22 +153,20 @@
"ws": "8.16.0", "ws": "8.16.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4", "zod": "^3.23.4",
"zod-form-data": "^2.0.2", "zod-form-data": "^2.0.2"
"@faker-js/faker": "^8.4.1",
"@tailwindcss/typography": "0.5.16"
}, },
"devDependencies": { "devDependencies": {
"@types/micromatch": "4.0.9",
"@types/qrcode": "^1.5.5",
"@types/nodemailer": "^6.4.15",
"@types/node-os-utils": "1.3.4",
"@types/adm-zip": "^0.5.5", "@types/adm-zip": "^0.5.5",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4", "@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.17.0", "@types/node": "^18.17.0",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6", "@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.15",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@types/ssh2": "1.15.1", "@types/ssh2": "1.15.1",
@ -196,6 +197,8 @@
] ]
}, },
"commitlint": { "commitlint": {
"extends": ["@commitlint/config-conventional"] "extends": [
"@commitlint/config-conventional"
]
} }
} }

View File

@ -118,12 +118,15 @@ importers:
'@ai-sdk/openai-compatible': '@ai-sdk/openai-compatible':
specifier: ^0.0.13 specifier: ^0.0.13
version: 0.0.13(zod@3.23.8) version: 0.0.13(zod@3.23.8)
'@codemirror/autocomplete':
specifier: ^6.18.6
version: 6.18.6
'@codemirror/lang-json': '@codemirror/lang-json':
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1 version: 6.0.1
'@codemirror/lang-yaml': '@codemirror/lang-yaml':
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1(@codemirror/view@6.29.0) version: 6.1.1
'@codemirror/language': '@codemirror/language':
specifier: ^6.10.1 specifier: ^6.10.1
version: 6.10.2 version: 6.10.2
@ -246,7 +249,7 @@ importers:
version: 4.23.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0) version: 4.23.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)
'@uiw/react-codemirror': '@uiw/react-codemirror':
specifier: ^4.22.1 specifier: ^4.22.1
version: 4.23.0(@babel/runtime@7.25.0)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1))(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 4.23.0(@babel/runtime@7.25.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@xterm/addon-attach': '@xterm/addon-attach':
specifier: 0.10.0 specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0) version: 0.10.0(@xterm/xterm@5.5.0)
@ -997,6 +1000,9 @@ packages:
'@codemirror/view': ^6.0.0 '@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0 '@lezer/common': ^1.0.0
'@codemirror/autocomplete@6.18.6':
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
'@codemirror/commands@6.6.0': '@codemirror/commands@6.6.0':
resolution: {integrity: sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==} resolution: {integrity: sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==}
@ -8018,6 +8024,13 @@ snapshots:
'@codemirror/view': 6.29.0 '@codemirror/view': 6.29.0
'@lezer/common': 1.2.1 '@lezer/common': 1.2.1
'@codemirror/autocomplete@6.18.6':
dependencies:
'@codemirror/language': 6.10.2
'@codemirror/state': 6.4.1
'@codemirror/view': 6.29.0
'@lezer/common': 1.2.1
'@codemirror/commands@6.6.0': '@codemirror/commands@6.6.0':
dependencies: dependencies:
'@codemirror/language': 6.10.2 '@codemirror/language': 6.10.2
@ -8030,16 +8043,14 @@ snapshots:
'@codemirror/language': 6.10.2 '@codemirror/language': 6.10.2
'@lezer/json': 1.0.2 '@lezer/json': 1.0.2
'@codemirror/lang-yaml@6.1.1(@codemirror/view@6.29.0)': '@codemirror/lang-yaml@6.1.1':
dependencies: dependencies:
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1) '@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.10.2 '@codemirror/language': 6.10.2
'@codemirror/state': 6.4.1 '@codemirror/state': 6.4.1
'@lezer/common': 1.2.1 '@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0 '@lezer/highlight': 1.2.0
'@lezer/yaml': 1.0.3 '@lezer/yaml': 1.0.3
transitivePeerDependencies:
- '@codemirror/view'
'@codemirror/language@6.10.2': '@codemirror/language@6.10.2':
dependencies: dependencies:
@ -10808,9 +10819,9 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.14.10 '@types/node': 20.14.10
'@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1))(@codemirror/commands@6.6.0)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)': '@uiw/codemirror-extensions-basic-setup@4.23.0(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)':
dependencies: dependencies:
'@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1) '@codemirror/autocomplete': 6.18.6
'@codemirror/commands': 6.6.0 '@codemirror/commands': 6.6.0
'@codemirror/language': 6.10.2 '@codemirror/language': 6.10.2
'@codemirror/lint': 6.8.1 '@codemirror/lint': 6.8.1
@ -10832,14 +10843,14 @@ snapshots:
'@codemirror/state': 6.4.1 '@codemirror/state': 6.4.1
'@codemirror/view': 6.29.0 '@codemirror/view': 6.29.0
'@uiw/react-codemirror@4.23.0(@babel/runtime@7.25.0)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1))(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': '@uiw/react-codemirror@4.23.0(@babel/runtime@7.25.0)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1(@lezer/common@1.2.1))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.25.0 '@babel/runtime': 7.25.0
'@codemirror/commands': 6.6.0 '@codemirror/commands': 6.6.0
'@codemirror/state': 6.4.1 '@codemirror/state': 6.4.1
'@codemirror/theme-one-dark': 6.1.2 '@codemirror/theme-one-dark': 6.1.2
'@codemirror/view': 6.29.0 '@codemirror/view': 6.29.0
'@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)(@lezer/common@1.2.1))(@codemirror/commands@6.6.0)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0) '@uiw/codemirror-extensions-basic-setup': 4.23.0(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.6.0)(@codemirror/language@6.10.2)(@codemirror/lint@6.8.1)(@codemirror/search@6.5.6)(@codemirror/state@6.4.1)(@codemirror/view@6.29.0)
codemirror: 6.0.1(@lezer/common@1.2.1) codemirror: 6.0.1(@lezer/common@1.2.1)
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)