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}