From 341a5310e1b21c8a144eba8ccc2acbb4d3f2fe54 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:42:20 +0300 Subject: [PATCH] feat(code): basic tabs vs spaces --- apps/client/src/widgets/layout/StatusBar.tsx | 101 ++++++++++++++---- apps/client/src/widgets/react/hooks.tsx | 18 +++- .../src/widgets/type_widgets/code/Code.tsx | 8 +- .../widgets/type_widgets/code/CodeMirror.tsx | 8 +- apps/server/src/routes/api/options.ts | 1 + apps/server/src/services/options_init.ts | 1 + .../codemirror/src/extensions/custom_tab.ts | 15 +-- packages/codemirror/src/index.ts | 30 +++++- packages/commons/src/lib/attribute_names.ts | 1 + packages/commons/src/lib/options_interface.ts | 1 + 10 files changed, 147 insertions(+), 37 deletions(-) diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index 0fc541a828..df1e5624cc 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -20,7 +20,7 @@ import { formatDateTime } from "../../utils/formatters"; import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions"; import Dropdown, { DropdownProps } from "../react/Dropdown"; import { FormDropdownDivider, FormListHeader, FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionInt } from "../react/hooks"; +import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks"; import Icon from "../react/Icon"; import LinkButton from "../react/LinkButton"; import { ParentComponent } from "../react/react_utils"; @@ -441,41 +441,93 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; /** - * Re-indents leading spaces on each line, converting from `fromWidth` indent units - * to `toWidth` indent units. Tabs and non-leading whitespace are preserved as-is. + * Converts the leading indentation on each line to a new style. Non-leading whitespace is preserved. + * + * - "spaces" source means leading runs of spaces are grouped by `fromWidth` to compute the indent level. + * - "tabs" source means each leading tab counts as one indent level (leading spaces are preserved as alignment). */ -function reindentSpaces(content: string, fromWidth: number, toWidth: number): string { - if (fromWidth === toWidth || fromWidth <= 0) return content; - return content.replace(/^( +)/gm, (leading) => { - const levels = Math.round(leading.length / fromWidth); - const remainder = leading.length - levels * fromWidth; - return " ".repeat(levels * toWidth + Math.max(remainder, 0)); +function convertIndentation( + content: string, + from: { useTabs: boolean; width: number }, + to: { useTabs: boolean; width: number } +): string { + if (from.useTabs === to.useTabs && from.width === to.width) return content; + const toUnit = to.useTabs ? "\t" : " ".repeat(to.width); + + return content.replace(/^[ \t]+/gm, (leading) => { + let levels: number; + let remainder = ""; + if (from.useTabs) { + const match = leading.match(/^(\t*)(.*)$/s)!; + levels = match[1].length; + remainder = match[2]; + } else { + const spaces = leading.length; + levels = from.width > 0 ? Math.round(spaces / from.width) : 0; + const aligned = levels * from.width; + remainder = spaces > aligned ? " ".repeat(spaces - aligned) : ""; + } + return toUnit.repeat(levels) + remainder; }); } function TabWidthSwitcher({ note, noteContext }: StatusBarContext) { const [ globalTabWidth ] = useTriliumOptionInt("codeNoteTabWidth"); + const [ globalUseTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs"); const [ noteTabWidth, setNoteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs, setNoteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); const effectiveTabWidth = noteTabWidth ?? globalTabWidth; - const hasPerNoteOverride = noteTabWidth != null; + const effectiveUseTabs = noteUseTabs ?? globalUseTabs; + const hasWidthOverride = noteTabWidth != null; + const hasStyleOverride = noteUseTabs != null; - const reindentTo = async (newWidth: number) => { - if (newWidth === effectiveTabWidth) return; + const reindentTo = async (targetUseTabs: boolean, targetWidth: number) => { const editor = await noteContext.getCodeEditor(); if (!editor) return; - const reindented = reindentSpaces(editor.getText(), effectiveTabWidth, newWidth); - if (reindented !== editor.getText()) { - editor.setText(reindented); + const converted = convertIndentation( + editor.getText(), + { useTabs: effectiveUseTabs, width: effectiveTabWidth }, + { useTabs: targetUseTabs, width: targetWidth } + ); + if (converted !== editor.getText()) { + editor.setText(converted); } - setNoteTabWidth(newWidth); + setNoteTabWidth(targetWidth); + setNoteUseTabs(targetUseTabs); }; + const statusText = effectiveUseTabs + ? t("status_bar.tab_width_tabs", { width: effectiveTabWidth }) + : t("status_bar.tab_width_spaces_short", { width: effectiveTabWidth }); + return (note.type === "code" && + + setNoteUseTabs(false)} + > + {t("status_bar.tab_width_style_spaces")} + + setNoteUseTabs(true)} + > + {t("status_bar.tab_width_style_tabs")} + + {hasStyleOverride && + setNoteUseTabs(null)}> + {t("status_bar.tab_width_use_default_style", { + style: globalUseTabs ? t("status_bar.tab_width_style_tabs") : t("status_bar.tab_width_style_spaces") + })} + + } + + {TAB_WIDTH_OPTIONS.map(size => ( ))} - {hasPerNoteOverride && + {hasWidthOverride && setNoteTabWidth(null)}> {t("status_bar.tab_width_use_default", { width: globalTabWidth })} } + {TAB_WIDTH_OPTIONS.map(size => ( reindentTo(size)} + key={`reindent-spaces-${size}`} + disabled={!effectiveUseTabs && effectiveTabWidth === size} + onClick={() => reindentTo(false, size)} > {t("status_bar.tab_width_spaces", { count: size })} ))} + reindentTo(true, effectiveTabWidth)} + > + {t("status_bar.tab_width_style_tabs")} + ); } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6c70703385..66a3e44dfa 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -664,13 +664,27 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F return [ labelValue, setter ] as const; } -export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType): [ number | undefined, (newValue: number) => void] { +/** + * Like {@link useNoteLabelBoolean} but returns `undefined` when the label is absent, allowing the caller + * to distinguish between "explicitly false" and "not set" (for inheriting from a global default). + */ +export function useNoteLabelOptionalBool(note: FNote | undefined | null, labelName: FilterLabelsByType): [ boolean | undefined, (newValue: boolean | null) => void] { + //@ts-expect-error `useNoteLabel` only accepts string labels but we need to be able to read boolean ones. + const [ value, setValue ] = useNoteLabel(note, labelName); + useDebugValue(labelName); + return [ + (value == null ? undefined : value !== "false"), + (newValue) => setValue(newValue === null ? null : String(newValue)) + ]; +} + +export function useNoteLabelInt(note: FNote | undefined | null, labelName: FilterLabelsByType): [ number | undefined, (newValue: number | null) => void] { //@ts-expect-error `useNoteLabel` only accepts string properties but we need to be able to read number ones. const [ value, setValue ] = useNoteLabel(note, labelName); useDebugValue(labelName); return [ (value ? parseInt(value, 10) : undefined), - (newValue) => setValue(String(newValue)) + (newValue) => setValue(newValue === null ? null : String(newValue)) ]; } diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index f5bbc02da6..17ef41c941 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -8,7 +8,7 @@ import appContext, { CommandListenerData } from "../../../components/app_context import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import utils from "../../../services/utils"; -import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; +import { useEditorSpacedUpdate, useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteBlob, useNoteLabelInt, useNoteLabelOptionalBool, useNoteProperty, useSyncedRef, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import { refToJQuerySelector } from "../../react/react_utils"; import TouchBar, { TouchBarButton } from "../../react/TouchBar"; import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants"; @@ -37,6 +37,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi const [ content, setContent ] = useState(""); const blob = useNoteBlob(note); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); useEffect(() => { if (!blob) return; @@ -57,6 +58,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi mime={note.mime} readOnly {...(noteTabWidth != null && { indentSize: noteTabWidth })} + {...(noteUseTabs != null && { useTabs: noteUseTabs })} /> ); } @@ -82,6 +84,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC const containerRef = useRef(null); const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled"); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); + const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); const mime = useNoteProperty(note, "mime"); const spacedUpdate = useEditorSpacedUpdate({ note, @@ -133,6 +136,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC }} {...editorProps} {...(noteTabWidth != null && { indentSize: noteTabWidth })} + {...(noteUseTabs != null && { useTabs: noteUseTabs })} /> @@ -151,6 +155,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta const [ codeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled"); const [ codeNoteTheme ] = useTriliumOption("codeNoteTheme"); const [ codeNoteTabWidth ] = useTriliumOption("codeNoteTabWidth"); + const [ codeNoteIndentWithTabs ] = useTriliumOptionBool("codeNoteIndentWithTabs"); // React to background color. const [ backgroundColor, setBackgroundColor ] = useState(); @@ -206,6 +211,7 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta containerRef={containerRef} lineWrapping={lineWrapping ?? codeLineWrapEnabled} indentSize={editorProps.indentSize ?? (parseInt(codeNoteTabWidth) || 4)} + useTabs={editorProps.useTabs ?? codeNoteIndentWithTabs} onInitialized={() => { if (externalContainerRef && containerRef.current) { externalContainerRef.current = containerRef.current; diff --git a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx index ecc5e2c658..609ffe0198 100644 --- a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx +++ b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx @@ -49,12 +49,14 @@ export default function CodeMirror({ className, content, mime, editorRef: extern // React to line wrapping. useEffect(() => codeEditorRef.current?.setLineWrapping(!!lineWrapping), [ lineWrapping ]); - // React to indent size changes. + // React to indent size / style changes. useEffect(() => { if (extraOpts.indentSize != null) { - codeEditorRef.current?.setIndentSize(extraOpts.indentSize); + codeEditorRef.current?.setIndent(extraOpts.indentSize, !!extraOpts.useTabs); + } else if (extraOpts.useTabs != null) { + codeEditorRef.current?.setUseTabs(extraOpts.useTabs); } - }, [ extraOpts.indentSize ]); + }, [ extraOpts.indentSize, extraOpts.useTabs ]); return (
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 137ab51cf3..3ab6ca0a7e 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -33,6 +33,7 @@ const ALLOWED_OPTIONS = new Set([
     "codeBlockTabWidth",
     "codeNoteTheme",
     "codeNoteTabWidth",
+    "codeNoteIndentWithTabs",
     "syncServerHost",
     "syncServerTimeout",
     "syncServerTimeoutTimeScale",
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index 94151b269c..1437a836ff 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -132,6 +132,7 @@ const defaultOptions: DefaultOption[] = [
     { name: "vimKeymapEnabled", value: "false", isSynced: false },
     { name: "codeLineWrapEnabled", value: "true", isSynced: false },
     { name: "codeNoteTabWidth", value: "4", isSynced: true },
+    { name: "codeNoteIndentWithTabs", value: "false", isSynced: true },
     {
         name: "codeNotesMimeTypes",
         value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
diff --git a/packages/codemirror/src/extensions/custom_tab.ts b/packages/codemirror/src/extensions/custom_tab.ts
index e0f39690a1..3cfe25e363 100644
--- a/packages/codemirror/src/extensions/custom_tab.ts
+++ b/packages/codemirror/src/extensions/custom_tab.ts
@@ -1,4 +1,5 @@
 import { indentLess, indentMore } from "@codemirror/commands";
+import { indentUnit } from "@codemirror/language";
 import { EditorSelection, EditorState, SelectionRange, type Transaction, type ChangeSpec } from "@codemirror/state";
 import type { KeyBinding } from "@codemirror/view";
 
@@ -53,11 +54,12 @@ export default smartIndentWithTab;
 function handleSingleLineSelection(state: EditorState, dispatch: (transaction: Transaction) => void) {
     const changes: ChangeSpec[] = [];
     const newSelections: SelectionRange[] = [];
+    const unit = state.facet(indentUnit);
 
-    // Single line selection, replace with tab.
+    // Single line selection, replace with indent unit.
     for (let range of state.selection.ranges) {
-        changes.push({ from: range.from, to: range.to, insert: "\t" });
-        newSelections.push(EditorSelection.cursor(range.from + 1));
+        changes.push({ from: range.from, to: range.to, insert: unit });
+        newSelections.push(EditorSelection.cursor(range.from + unit.length));
     }
 
     dispatch(
@@ -75,6 +77,7 @@ function handleSingleLineSelection(state: EditorState, dispatch: (transaction: T
 function handleEmptySelections(state: EditorState, dispatch: (transaction: Transaction) => void) {
     const changes: ChangeSpec[] = [];
     const newSelections: SelectionRange[] = [];
+    const unit = state.facet(indentUnit);
 
     for (let range of state.selection.ranges) {
         const line = state.doc.lineAt(range.head);
@@ -84,9 +87,9 @@ function handleEmptySelections(state: EditorState, dispatch: (transaction: Trans
             // Only whitespace before cursor → indent line
             return indentMore({ state, dispatch });
         } else {
-            // Insert tab character at cursor
-            changes.push({ from: range.head, to: range.head, insert: "\t" });
-            newSelections.push(EditorSelection.cursor(range.head + 1));
+            // Insert configured indent unit at cursor
+            changes.push({ from: range.head, to: range.head, insert: unit });
+            newSelections.push(EditorSelection.cursor(range.head + unit.length));
         }
     }
 
diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts
index 7b868c859a..516ab8e686 100644
--- a/packages/codemirror/src/index.ts
+++ b/packages/codemirror/src/index.ts
@@ -34,11 +34,17 @@ export interface EditorConfig {
     /** Disables some of the nice-to-have features (bracket matching, syntax highlighting, indentation markers) in order to improve performance. */
     preferPerformance?: boolean;
     tabIndex?: number;
-    /** The number of spaces used for indentation. Defaults to 4. */
+    /** The number of spaces used for indentation (also used as the tab display width). Defaults to 4. */
     indentSize?: number;
+    /** If true, indent using a tab character instead of spaces. Defaults to false. */
+    useTabs?: boolean;
     onContentChanged?: ContentChangedListener;
 }
 
+function buildIndentUnit(indentSize: number, useTabs: boolean) {
+    return useTabs ? "\t" : " ".repeat(indentSize);
+}
+
 export default class CodeMirror extends EditorView {
 
     private config: EditorConfig;
@@ -72,7 +78,10 @@ export default class CodeMirror extends EditorView {
             searchHighlightCompartment.of([]),
             highlightActiveLine(),
             lineNumbers(),
-            indentUnitCompartment.of(indentUnit.of(" ".repeat(config.indentSize ?? 4))),
+            indentUnitCompartment.of([
+                indentUnit.of(buildIndentUnit(config.indentSize ?? 4, !!config.useTabs)),
+                EditorState.tabSize.of(config.indentSize ?? 4)
+            ]),
             keymap.of([
                 ...preventCtrlEnterKeymap,
                 ...defaultKeymap,
@@ -173,12 +182,25 @@ export default class CodeMirror extends EditorView {
         });
     }
 
-    setIndentSize(size: number) {
+    setIndent(size: number, useTabs: boolean) {
+        this.config.indentSize = size;
+        this.config.useTabs = useTabs;
         this.dispatch({
-            effects: [ this.indentUnitCompartment.reconfigure(indentUnit.of(" ".repeat(size))) ]
+            effects: [ this.indentUnitCompartment.reconfigure([
+                indentUnit.of(buildIndentUnit(size, useTabs)),
+                EditorState.tabSize.of(size)
+            ]) ]
         });
     }
 
+    setIndentSize(size: number) {
+        this.setIndent(size, !!this.config.useTabs);
+    }
+
+    setUseTabs(useTabs: boolean) {
+        this.setIndent(this.config.indentSize ?? 4, useTabs);
+    }
+
     /**
      * Clears the history of undo/redo. Generally useful when changing to a new document.
      */
diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts
index 93c99dc3ee..340714e3d9 100644
--- a/packages/commons/src/lib/attribute_names.ts
+++ b/packages/commons/src/lib/attribute_names.ts
@@ -71,6 +71,7 @@ type Labels = {
     "disabled:webViewSrc": string;
     readOnly: boolean;
     tabWidth: number;
+    indentWithTabs: boolean;
     mapType: string;
     mapRootNoteId: string;
 
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index d89f1460fd..5ecc585e61 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -162,6 +162,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions