optional indentation guides in the compose/YAML editor

This commit is contained in:
jarek committed 2026-08-16 08:15:30 +02:00
1 parent cda446a232
commit 0a08c94287
10 files changed
+109 -15

No files matched your search

+1
View File
@@ -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",
+21 -3
View File
@@ -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
@@ -0,0 +1,37 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't touch the live editor when the
// admin is editing the global default while logged in (their own per-user preference
// still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(false);
$effect(() => {
checked = $themeStore.editorIndentGuides;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('editorIndentGuides', value, userId, skipApply);
toast.success(value ? 'Indentation guides enabled' : 'Indentation guides disabled');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Indentation guides</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Vertical guides showing nesting depth in the code editor.</p>
</div>
+11 -4
View File
@@ -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<void> {
const updates: Promise<void>[] = [];
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);
}
+6 -2
View File
@@ -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);
@@ -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 });
+13 -1
View File
@@ -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<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
showImageChangelogLinks: true,
showWhatsNew: true,
animateIcons: true,
editorIndentGuides: false,
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
@@ -269,6 +272,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
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)
};
+8 -4
View File
@@ -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);
+2
View File
@@ -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 @@
<ThemeSelector userId={profile.id} />
<ColoredActionsToggle userId={profile.id} />
<AnimateIconsToggle userId={profile.id} />
<IndentGuidesToggle userId={profile.id} />
</Card.Content>
</Card.Root>
@@ -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:
<ThemeSelector />
<ColoredActionsToggle />
<AnimateIconsToggle />
<IndentGuidesToggle />
{#if $authStore.authEnabled}
<div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />