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