diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index df1e5624cc..2be8a8d40a 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -33,6 +33,7 @@ import SimilarNotesTab from "../ribbon/SimilarNotesTab"; import { useAttachments } from "../type_widgets/Attachment"; import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector"; import Breadcrumb from "./Breadcrumb"; +import { convertIndentation } from "./reindentation"; interface StatusBarContext { note: FNote; @@ -440,37 +441,6 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { //#region Tab width switcher const TAB_WIDTH_OPTIONS = [1, 2, 3, 4, 6, 8] as const; -/** - * 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 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"); diff --git a/apps/client/src/widgets/layout/reindentation.spec.ts b/apps/client/src/widgets/layout/reindentation.spec.ts new file mode 100644 index 0000000000..5487342dce --- /dev/null +++ b/apps/client/src/widgets/layout/reindentation.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { convertIndentation } from "./reindentation"; + +describe("convertIndentation", () => { + it("returns content unchanged when source and target match", () => { + const content = " const x = 1;\n const y = 2;\n"; + expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 4 })).toBe(content); + }); + + it("returns content unchanged for zero or negative widths", () => { + const content = " x\n"; + expect(convertIndentation(content, { useTabs: false, width: 0 }, { useTabs: false, width: 4 })).toBe(content); + expect(convertIndentation(content, { useTabs: false, width: 4 }, { useTabs: false, width: 0 })).toBe(content); + }); + + it("converts spaces to a narrower width", () => { + const input = " a\n b\n c\n"; + const expected = " a\n b\n c\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected); + }); + + it("converts spaces to a wider width", () => { + const input = " a\n b\n"; + const expected = " a\n b\n"; + expect(convertIndentation(input, { useTabs: false, width: 2 }, { useTabs: false, width: 4 })).toBe(expected); + }); + + it("converts spaces to tabs", () => { + const input = " a\n b\n"; + const expected = "\ta\n\t\tb\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected); + }); + + it("converts tabs to spaces", () => { + const input = "\ta\n\t\tb\n"; + const expected = " a\n b\n"; + expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(expected); + }); + + it("converts tabs to a different tab display width", () => { + // When both source and target are tabs, the content doesn't change (tab count is preserved) + // regardless of visual tab width. + const input = "\ta\n\t\tb\n"; + expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: true, width: 2 })).toBe(input); + }); + + it("handles mixed tabs and spaces on the same line (tab then spaces)", () => { + // One tab (→ col 4) + 2 spaces → 6 columns → 1 level + 2 remainder spaces at width=4. + // Target spaces width=4: 1 level (4 spaces) + 2 remainder = 6 spaces. + const input = "\t statement;\n"; + expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" statement;\n"); + }); + + it("preserves alignment remainder when converting spaces to tabs", () => { + // 6 spaces at width=4 → 1 level + 2 remainder spaces. + const input = " alignedText\n"; + const expected = "\t alignedText\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(expected); + }); + + it("preserves alignment remainder when converting spaces to narrower spaces", () => { + // 5 spaces at width=4 → 1 level + 1 remainder → 1 level at width 2 = 2 spaces + 1 remainder = 3 spaces. + const input = " x\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(" x\n"); + }); + + it("handles interleaved spaces and tabs (space, then tab)", () => { + // " \t" at tabWidth=4: 2 cols (spaces), then tab advances to next multiple of 4 = col 4. + // Total 4 cols = 1 level. + const input = " \tstmt\n"; + expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 4 })).toBe(" stmt\n"); + }); + + it("does not touch non-leading whitespace", () => { + const input = "const a = \" \\t \";\n if (x)\n"; + const expected = "const a = \" \\t \";\n if (x)\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected); + }); + + it("leaves blank lines alone", () => { + const input = " a\n\n b\n"; + const expected = " a\n\n b\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: false, width: 2 })).toBe(expected); + }); + + it("handles mixed indentation styles across lines", () => { + // Line 1 uses tabs, line 2 uses spaces. + const input = "\t\tnested\n alsoNested\n"; + // At from=tabs,width=4: line 1 has 8 cols → 2 levels. Line 2 has 8 spaces → 8 cols → 2 levels. + // Target = spaces width 2 → both lines become " " (4 spaces). + expect(convertIndentation(input, { useTabs: true, width: 4 }, { useTabs: false, width: 2 })).toBe(" nested\n alsoNested\n"); + }); + + it("preserves content without leading whitespace", () => { + const input = "no indent\nalso no indent\n"; + expect(convertIndentation(input, { useTabs: false, width: 4 }, { useTabs: true, width: 4 })).toBe(input); + }); +}); diff --git a/apps/client/src/widgets/layout/reindentation.ts b/apps/client/src/widgets/layout/reindentation.ts new file mode 100644 index 0000000000..d08234ff05 --- /dev/null +++ b/apps/client/src/widgets/layout/reindentation.ts @@ -0,0 +1,41 @@ +export interface IndentStyle { + useTabs: boolean; + width: number; +} + +/** + * Computes the visual column span of a leading-whitespace run. Tabs advance to the next + * multiple of `tabWidth`; spaces advance by 1. + */ +function measureLeadingColumns(leading: string, tabWidth: number): number { + let cols = 0; + for (const ch of leading) { + if (ch === "\t") { + cols += tabWidth - (cols % tabWidth); + } else { + cols += 1; + } + } + return cols; +} + +/** + * Rewrites the leading whitespace on every line, converting it from the `from` style to the `to` + * style. Non-leading whitespace is preserved. + * + * Handles lines with mixed tabs and spaces in the leading whitespace by measuring the total visual + * column span (using `from.width` as the tab stop) and then dividing into `to.width`-sized levels. + * Any leftover columns that don't fit a whole level are emitted as spaces (alignment preserved). + */ +export function convertIndentation(content: string, from: IndentStyle, to: IndentStyle): string { + if (from.useTabs === to.useTabs && from.width === to.width) return content; + if (from.width <= 0 || to.width <= 0) return content; + const toUnit = to.useTabs ? "\t" : " ".repeat(to.width); + + return content.replace(/^[ \t]+/gm, (leading) => { + const cols = measureLeadingColumns(leading, from.width); + const levels = Math.floor(cols / from.width); + const remainder = cols % from.width; + return toUnit.repeat(levels) + " ".repeat(remainder); + }); +}