diff --git a/Dockerfile b/Dockerfile index 6f8aa0d..9a08239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Copy emergency scripts (only the emergency subfolder, not license generation scripts) COPY scripts/emergency/ ./scripts/ -RUN chmod +x ./scripts/*.sh 2>/dev/null || true +RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true # Create directories with proper ownership RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ecbc9b2..3f00ec4 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,6 +12,60 @@ if [ "$(id -u)" = "0" ]; then RUNNING_AS_ROOT=true fi +# === Non-root mode (user: directive in compose) === +# If container started as non-root, skip all user management and run directly +if [ "$RUNNING_AS_ROOT" = "false" ]; then + echo "Running as user $(id -u):$(id -g) (set via container user directive)" + + # Ensure data directories exist (user must have write access to DATA_DIR via volume mount) + DATA_DIR="${DATA_DIR:-/app/data}" + if [ ! -d "$DATA_DIR/db" ]; then + echo "Creating database directory at $DATA_DIR/db" + mkdir -p "$DATA_DIR/db" 2>/dev/null || { + echo "ERROR: Cannot create $DATA_DIR/db directory" + echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)" + echo "" + echo "Example docker-compose.yml:" + echo " volumes:" + echo " - ./data:/app/data # This directory must be writable by user $(id -u)" + exit 1 + } + fi + if [ ! -d "$DATA_DIR/stacks" ]; then + mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true + fi + + # Check Docker socket access if mounted + SOCKET_PATH="/var/run/docker.sock" + if [ -S "$SOCKET_PATH" ]; then + if test -r "$SOCKET_PATH" 2>/dev/null; then + echo "Docker socket accessible at $SOCKET_PATH" + # Detect hostname from Docker if not set + if [ -z "$DOCKHAND_HOSTNAME" ]; then + DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p') + if [ -n "$DETECTED_HOSTNAME" ]; then + export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME" + echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME" + fi + fi + else + SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown") + echo "WARNING: Docker socket not readable by user $(id -u)" + echo "Add --group-add $SOCKET_GID to your docker run command" + fi + else + echo "No Docker socket found at $SOCKET_PATH" + echo "Configure Docker environments via the web UI (Settings > Environments)" + fi + + # Run directly as current user (no su-exec needed) + if [ "$1" = "" ]; then + exec bun run ./build/index.js + else + exec "$@" + fi +fi + # === User Setup === # Root mode: PUID=0 requested OR already running as root with default PUID/PGID if [ "$PUID" = "0" ]; then diff --git a/src/images/logo.webp b/src/images/logo.webp deleted file mode 100644 index 421abdf..0000000 Binary files a/src/images/logo.webp and /dev/null differ diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index f49406f..4cac179 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -3,11 +3,51 @@ import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; - import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'; + import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; + // Simple dotenv/env file language parser + const dotenvParser: StreamParser<{ inValue: boolean }> = { + startState() { + return { inValue: false }; + }, + token(stream, state) { + // Start of line + if (stream.sol()) { + state.inValue = false; + // Skip leading whitespace + stream.eatSpace(); + // Comment line + if (stream.peek() === '#') { + stream.skipToEnd(); + return 'comment'; + } + } + // If in value part, consume the rest + if (state.inValue) { + stream.skipToEnd(); + return 'string'; + } + // Variable name before = + if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) { + if (stream.peek() === '=') { + return 'variableName.definition'; + } + return 'variableName'; + } + // Equals sign - switch to value mode + if (stream.eat('=')) { + state.inValue = true; + return 'operator'; + } + // Skip anything else + stream.next(); + return null; + } + }; + // Docker Compose keywords for autocomplete const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version']; @@ -453,6 +493,9 @@ case 'sh': // No dedicated shell/dockerfile support, use basic highlighting return []; + case 'dotenv': + case 'env': + return StreamLanguage.define(dotenvParser); default: return []; } @@ -542,6 +585,13 @@ // Track if we're initialized (prevents multiple createEditor calls) let initialized = false; + // Debounce timer for marker updates (prevents flicker during fast typing) + let markerUpdateTimer: ReturnType | null = null; + const MARKER_UPDATE_DEBOUNCE_MS = 300; + + // Track last applied markers to avoid redundant updates + let lastAppliedMarkersJson = ''; + function createEditor() { if (!container || view || initialized) return; initialized = true; @@ -551,12 +601,14 @@ : [dockhandLight, syntaxHighlighting(defaultHighlightStyle)]; // Build autocompletion config - add Docker Compose completions for YAML + // Note: activateOnTyping can interfere with key repeat, so we disable it + // Users can still trigger autocomplete manually with Ctrl+Space const autocompletionConfig = language === 'yaml' ? autocompletion({ override: [composeCompletions, composeValueCompletions], - activateOnTyping: true + activateOnTyping: false }) - : autocompletion(); + : autocompletion({ activateOnTyping: false }); const extensions = [ lineNumbers(), @@ -594,18 +646,25 @@ extensions }); - // Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener + // Custom transaction handler - applies transactions synchronously but defers callback // Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104 const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => { if (!view) return; - // Apply all transactions + // Apply all transactions synchronously (required by CodeMirror) view.update(trs); // Check if any transaction changed the document const lastChangingTr = trs.findLast(tr => tr.docChanged); if (lastChangingTr && onchangeRef) { - onchangeRef(lastChangingTr.newDoc.toString()); + // Defer callback to next microtask to avoid blocking input handling + // This allows key repeat to work properly + const newContent = lastChangingTr.newDoc.toString(); + queueMicrotask(() => { + if (onchangeRef) { + onchangeRef(newContent); + } + }); } }; @@ -615,7 +674,6 @@ dispatchTransactions }); - // Push initial markers if provided if (variableMarkers.length > 0) { view.dispatch({ @@ -625,11 +683,16 @@ } function destroyEditor() { + if (markerUpdateTimer) { + clearTimeout(markerUpdateTimer); + markerUpdateTimer = null; + } if (view) { view.destroy(); view = null; } initialized = false; + lastAppliedMarkersJson = ''; } // Get current editor content @@ -656,11 +719,35 @@ } // Update variable markers - this is the key method for parent to call - export function updateVariableMarkers(markers: VariableMarker[]) { - if (view) { - view.dispatch({ - effects: updateMarkersEffect.of(markers) - }); + // Debounced to prevent flicker during fast typing + export function updateVariableMarkers(markers: VariableMarker[], immediate = false) { + if (!view) return; + + // Check if markers actually changed (compare by content, not reference) + const newJson = JSON.stringify(markers); + if (newJson === lastAppliedMarkersJson) { + return; // No change, skip update + } + + // Clear any pending update + if (markerUpdateTimer) { + clearTimeout(markerUpdateTimer); + markerUpdateTimer = null; + } + + const applyUpdate = () => { + if (view) { + lastAppliedMarkersJson = newJson; + view.dispatch({ + effects: updateMarkersEffect.of(markers) + }); + } + }; + + if (immediate) { + applyUpdate(); + } else { + markerUpdateTimer = setTimeout(applyUpdate, MARKER_UPDATE_DEBOUNCE_MS); } } @@ -693,12 +780,11 @@ }); // Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers) + // Uses the debounced update to prevent flicker during fast typing $effect(() => { const markers = variableMarkers; if (view && markers) { - view.dispatch({ - effects: updateMarkersEffect.of(markers) - }); + updateVariableMarkers(markers); } }); @@ -706,7 +792,6 @@
e.stopPropagation()} >