diff --git a/package.json b/package.json
index 9161d8f..b9f211a 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,7 @@
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "0.1.2",
+ "@replit/codemirror-indentation-markers": "6.5.3",
"ansi_up": "6.0.6",
"argon2": "0.41.1",
"better-sqlite3": "11.7.0",
diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte
index 996b439..3936c64 100644
--- a/src/lib/components/CodeEditor.svelte
+++ b/src/lib/components/CodeEditor.svelte
@@ -9,6 +9,9 @@
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';
+ import { indentationMarkers } from '@replit/codemirror-indentation-markers';
+ import { themeStore } from '$lib/stores/theme';
+ import { get } from 'svelte/store';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { toml } from '@codemirror/legacy-modes/mode/toml';
@@ -707,6 +710,19 @@
EditorView.lineWrapping,
EditorState.tabSize.of(2),
getLanguageExtension(language),
+ // Vertical indentation guides, opt-in per user (#1410). Subtle tones for both themes.
+ ...(get(themeStore).editorIndentGuides
+ ? [indentationMarkers({
+ highlightActiveBlock: true,
+ hideFirstIndent: true,
+ colors: {
+ light: '#e2e8f0',
+ dark: '#3a3f4b',
+ activeLight: '#94a3b8',
+ activeDark: '#5b6472'
+ }
+ })]
+ : []),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat();
@@ -836,16 +852,18 @@
// Track previous values for comparison
let prevLanguage = $state(language);
let prevTheme = $state(theme);
+ let prevIndentGuides = $state($themeStore.editorIndentGuides);
- // Recreate editor if language or theme changes
+ // Recreate editor if language, theme, or the indent-guides preference changes
$effect(() => {
const currentLanguage = language;
const currentTheme = theme;
+ const currentIndentGuides = $themeStore.editorIndentGuides;
- // Only recreate if language or theme actually changed
- if (view && (currentLanguage !== prevLanguage || currentTheme !== prevTheme)) {
+ if (view && (currentLanguage !== prevLanguage || currentTheme !== prevTheme || currentIndentGuides !== prevIndentGuides)) {
prevLanguage = currentLanguage;
prevTheme = currentTheme;
+ prevIndentGuides = currentIndentGuides;
const currentContent = view.state.doc.toString();
destroyEditor();
value = currentContent; // Preserve content
diff --git a/src/lib/components/IndentGuidesToggle.svelte b/src/lib/components/IndentGuidesToggle.svelte
new file mode 100644
index 0000000..e8c3094
--- /dev/null
+++ b/src/lib/components/IndentGuidesToggle.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
Vertical guides showing nesting depth in the code editor.
+
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
index 4a1eefa..5520a95 100644
--- a/src/lib/server/db.ts
+++ b/src/lib/server/db.ts
@@ -536,8 +536,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: string;
+ editorIndentGuides: boolean;
}> {
- const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
+ const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize, editorIndentGuides] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
@@ -547,7 +548,8 @@ export async function getUserThemePreferences(userId: number): Promise<{
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons'),
getUserSetting(userId, 'colored_action_buttons'),
- getUserSetting(userId, 'action_icon_size')
+ getUserSetting(userId, 'action_icon_size'),
+ getUserSetting(userId, 'editor_indent_guides')
]);
return {
lightTheme: lightTheme || 'default',
@@ -561,13 +563,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
animateIcons: animateIcons === 'false' ? false : true,
// Default OFF — only true when explicitly stored
coloredActionButtons: coloredActionButtons === 'true',
- actionIconSize: actionIconSize || 'normal'
+ actionIconSize: actionIconSize || 'normal',
+ // Default OFF — only true when explicitly stored (#1410)
+ editorIndentGuides: editorIndentGuides === 'true'
};
}
export async function setUserThemePreferences(
userId: number,
- prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
+ prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string; editorIndentGuides?: boolean }
): Promise {
const updates: Promise[] = [];
if (prefs.lightTheme !== undefined) {
@@ -600,6 +604,9 @@ export async function setUserThemePreferences(
if (prefs.actionIconSize !== undefined) {
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
}
+ if (prefs.editorIndentGuides !== undefined) {
+ updates.push(setUserSetting(userId, 'editor_indent_guides', prefs.editorIndentGuides ? 'true' : 'false'));
+ }
await Promise.all(updates);
}
diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts
index ebc3a76..983f7af 100644
--- a/src/lib/stores/theme.ts
+++ b/src/lib/stores/theme.ts
@@ -34,6 +34,7 @@ export interface ThemePreferences {
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: ActionIconSize;
+ editorIndentGuides: boolean;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -48,7 +49,8 @@ const defaultPrefs: ThemePreferences = {
editorFont: 'system-mono',
animateIcons: true,
coloredActionButtons: false,
- actionIconSize: 'normal'
+ actionIconSize: 'normal',
+ editorIndentGuides: false
};
// Font size scale mapping
@@ -125,7 +127,9 @@ function createThemeStore() {
: !!(data.animateIcons ?? data.animate_icons),
// Default OFF (#1072)
coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false),
- actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize
+ actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize,
+ // Default OFF (#1410)
+ editorIndentGuides: !!(data.editorIndentGuides ?? data.editor_indent_guides ?? false)
};
set(prefs);
saveToStorage(prefs);
diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts
index 0359233..c0f0d7a 100644
--- a/src/routes/api/profile/preferences/+server.ts
+++ b/src/routes/api/profile/preferences/+server.ts
@@ -47,7 +47,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
- const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
+ const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string; editorIndentGuides?: boolean } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -105,6 +105,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.animateIcons = data.animateIcons;
}
+ if (data.editorIndentGuides !== undefined) {
+ if (typeof data.editorIndentGuides !== 'boolean') {
+ return json({ error: 'Invalid editorIndentGuides' }, { status: 400 });
+ }
+ updates.editorIndentGuides = data.editorIndentGuides;
+ }
+
if (data.coloredActionButtons !== undefined) {
if (typeof data.coloredActionButtons !== 'boolean') {
return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts
index 71cf52b..6bb73fc 100644
--- a/src/routes/api/settings/general/+server.ts
+++ b/src/routes/api/settings/general/+server.ts
@@ -117,6 +117,8 @@ export interface GeneralSettings {
showWhatsNew: boolean;
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
+ // Vertical indentation guides in the code editor (#1410)
+ editorIndentGuides: boolean;
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
protectScannerImages: boolean;
// Scanner Advanced settings (#1219). Empty = use auto-detection.
@@ -161,6 +163,7 @@ const DEFAULT_SETTINGS: Omit {
showImageChangelogLinks,
showWhatsNew,
animateIcons,
+ editorIndentGuides,
protectScannerImages,
defaultScannerNetworkMode,
defaultScannerDnsRaw
@@ -319,6 +323,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
getSetting('animate_icons'),
+ getSetting('editor_indent_guides'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
@@ -373,6 +378,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
+ editorIndentGuides: editorIndentGuides ?? DEFAULT_SETTINGS.editorIndentGuides,
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw)
@@ -393,7 +399,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
- const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
+ const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -557,6 +563,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
+ if (editorIndentGuides !== undefined && typeof editorIndentGuides === 'boolean') {
+ await setSetting('editor_indent_guides', editorIndentGuides);
+ }
if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') {
await setSetting('protect_scanner_images', protectScannerImages);
}
@@ -623,6 +632,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
showImageChangelogLinksVal,
showWhatsNewVal,
animateIconsVal,
+ editorIndentGuidesVal,
protectScannerImagesVal,
defaultScannerNetworkModeVal,
defaultScannerDnsRawVal
@@ -673,6 +683,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
getSetting('animate_icons'),
+ getSetting('editor_indent_guides'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
@@ -728,6 +739,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: showWhatsNewVal ?? DEFAULT_SETTINGS.showWhatsNew,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
+ editorIndentGuides: editorIndentGuidesVal ?? DEFAULT_SETTINGS.editorIndentGuides,
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
};
diff --git a/src/routes/api/settings/theme/+server.ts b/src/routes/api/settings/theme/+server.ts
index b196089..25e5970 100644
--- a/src/routes/api/settings/theme/+server.ts
+++ b/src/routes/api/settings/theme/+server.ts
@@ -16,7 +16,8 @@ const DEFAULT_THEME_SETTINGS = {
editorFont: 'system-mono',
animateIcons: true,
coloredActionButtons: false,
- actionIconSize: 'normal'
+ actionIconSize: 'normal',
+ editorIndentGuides: false
};
export const GET: RequestHandler = async () => {
@@ -31,7 +32,8 @@ export const GET: RequestHandler = async () => {
editorFont,
animateIcons,
coloredActionButtons,
- actionIconSize
+ actionIconSize,
+ editorIndentGuides
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
@@ -42,7 +44,8 @@ export const GET: RequestHandler = async () => {
getSetting('theme_editor_font'),
getSetting('animate_icons'),
getSetting('colored_action_buttons'),
- getSetting('action_icon_size')
+ getSetting('action_icon_size'),
+ getSetting('editor_indent_guides')
]);
return json({
@@ -55,7 +58,8 @@ export const GET: RequestHandler = async () => {
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons,
coloredActionButtons: coloredActionButtons ?? DEFAULT_THEME_SETTINGS.coloredActionButtons,
- actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize
+ actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize,
+ editorIndentGuides: editorIndentGuides ?? DEFAULT_THEME_SETTINGS.editorIndentGuides
});
} catch (error) {
console.error('Failed to get theme settings:', error);
diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte
index 9ae7d90..e6f5572 100644
--- a/src/routes/profile/+page.svelte
+++ b/src/routes/profile/+page.svelte
@@ -41,6 +41,7 @@
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
+ import IndentGuidesToggle from '$lib/components/IndentGuidesToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import { themeStore } from '$lib/stores/theme';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -731,6 +732,7 @@
+
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte
index ae4a978..b4e786c 100644
--- a/src/routes/settings/general/GeneralTab.svelte
+++ b/src/routes/settings/general/GeneralTab.svelte
@@ -16,6 +16,7 @@
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
+ import IndentGuidesToggle from '$lib/components/IndentGuidesToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -511,6 +512,7 @@ services:
+
{#if $authStore.authEnabled}