Skip to content

Commit 8bb8ed4

Browse files
authored
fix: preserve capture-format spacing for insert-at-end (#1119)
* fix: preserve insert-at-end capture spacing * fix: align create-if-not-found insert-at-end spacing
1 parent 0a7acfe commit 8bb8ed4

File tree

2 files changed

+200
-2
lines changed

2 files changed

+200
-2
lines changed

src/formatters/captureChoiceFormatter-frontmatter.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,165 @@ describe('CaptureChoiceFormatter insert after blank lines', () => {
363363
});
364364
});
365365

366+
describe('CaptureChoiceFormatter insert after end-of-section spacing', () => {
367+
beforeEach(() => {
368+
vi.resetAllMocks();
369+
(global as any).navigator = {
370+
clipboard: {
371+
readText: vi.fn().mockResolvedValue(''),
372+
},
373+
};
374+
});
375+
376+
const createFormatter = () => {
377+
const app = createMockApp();
378+
const plugin = {
379+
settings: {
380+
enableTemplatePropertyTypes: false,
381+
globalVariables: {},
382+
showCaptureNotification: false,
383+
showInputCancellationNotification: true,
384+
},
385+
} as any;
386+
const formatter = new CaptureChoiceFormatter(app, plugin);
387+
const file = createTFile('EndOfSection.md');
388+
389+
return { app, formatter, file };
390+
};
391+
392+
const createInsertAfterChoice = (
393+
after: string,
394+
overrides: Partial<ICaptureChoice['insertAfter']> = {},
395+
): ICaptureChoice =>
396+
createChoice({
397+
insertAfter: {
398+
enabled: true,
399+
after,
400+
insertAtEnd: true,
401+
considerSubsections: false,
402+
createIfNotFound: false,
403+
createIfNotFoundLocation: '',
404+
inline: false,
405+
replaceExisting: false,
406+
blankLineAfterMatchMode: 'auto',
407+
...overrides,
408+
},
409+
});
410+
411+
it('preserves trailing format spacing across repeated insert-at-end captures at EOF', async () => {
412+
const { formatter, file } = createFormatter();
413+
const choice = createInsertAfterChoice('# Journal');
414+
const initial = ['# Journal', '', '10:00', 'Some data', ''].join('\n');
415+
416+
const first = await formatter.formatContentWithFile(
417+
'18:11\nTest\n\n',
418+
choice,
419+
initial,
420+
file,
421+
);
422+
423+
const second = await formatter.formatContentWithFile(
424+
'18:12\nTest2\n\n',
425+
choice,
426+
first,
427+
file,
428+
);
429+
430+
expect(second).toBe(
431+
['# Journal', '', '10:00', 'Some data', '18:11', 'Test', '', '18:12', 'Test2', '', ''].join('\n'),
432+
);
433+
});
434+
435+
it('keeps expected spacing for leading-newline capture formats', async () => {
436+
const { formatter, file } = createFormatter();
437+
const choice = createInsertAfterChoice('# Journal');
438+
const initial = ['# Journal', '', '10:00', 'Some data', ''].join('\n');
439+
440+
const first = await formatter.formatContentWithFile(
441+
'\n18:11\nTest3',
442+
choice,
443+
initial,
444+
file,
445+
);
446+
447+
const second = await formatter.formatContentWithFile(
448+
'\n18:12\nTest4',
449+
choice,
450+
first,
451+
file,
452+
);
453+
454+
expect(second).toBe(
455+
['# Journal', '', '10:00', 'Some data', '', '18:11', 'Test3', '', '18:12', 'Test4'].join('\n'),
456+
);
457+
});
458+
459+
it('preserves spacing for non-heading insert-at-end targets at EOF', async () => {
460+
const { formatter, file } = createFormatter();
461+
const choice = createInsertAfterChoice('Target');
462+
const initial = ['Target', 'Existing', ''].join('\n');
463+
464+
const first = await formatter.formatContentWithFile(
465+
'One\n\n',
466+
choice,
467+
initial,
468+
file,
469+
);
470+
471+
const second = await formatter.formatContentWithFile(
472+
'Two\n\n',
473+
choice,
474+
first,
475+
file,
476+
);
477+
478+
expect(second).toBe(['Target', 'Existing', 'One', '', 'Two', '', ''].join('\n'));
479+
});
480+
481+
it('does not change behavior when insert-at-end is disabled', async () => {
482+
const { formatter, file } = createFormatter();
483+
const choice = createInsertAfterChoice('# Journal', { insertAtEnd: false });
484+
const initial = ['# Journal', '', '10:00', 'Some data', ''].join('\n');
485+
486+
const result = await formatter.formatContentWithFile(
487+
'18:13\nTest5\n\n',
488+
choice,
489+
initial,
490+
file,
491+
);
492+
493+
expect(result).toBe(
494+
['# Journal', '', '18:13', 'Test5', '', '10:00', 'Some data', ''].join('\n'),
495+
);
496+
});
497+
498+
it('uses EOF spacing logic when create-if-not-found inserts at cursor with insert-at-end', async () => {
499+
const { app, formatter, file } = createFormatter();
500+
const choice = createInsertAfterChoice('# Missing', {
501+
createIfNotFound: true,
502+
createIfNotFoundLocation: 'cursor',
503+
});
504+
(app.workspace.getActiveViewOfType as any).mockReturnValue({
505+
editor: {
506+
getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }),
507+
getSelection: vi.fn().mockReturnValue(''),
508+
},
509+
});
510+
const initial = ['# Journal', '', '10:00', 'Some data', '', ''].join('\n');
511+
512+
const result = await formatter.formatContentWithFile(
513+
'18:14\nTest6\n\n',
514+
choice,
515+
initial,
516+
file,
517+
);
518+
519+
expect(result).toBe(
520+
['# Journal', '', '10:00', 'Some data', '', '# Missing', '18:14', 'Test6', '', ''].join('\n'),
521+
);
522+
});
523+
});
524+
366525
describe('CaptureChoiceFormatter insert after inline', () => {
367526
beforeEach(() => {
368527
vi.resetAllMocks();

src/formatters/captureChoiceFormatter.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,37 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
263263
return position;
264264
}
265265

266+
private findInsertAfterPositionAtSectionEnd(
267+
lines: string[],
268+
sectionEndIndex: number,
269+
body: string,
270+
): number {
271+
if (sectionEndIndex < 0) return sectionEndIndex;
272+
273+
let position = sectionEndIndex;
274+
let i = sectionEndIndex + 1;
275+
276+
while (i < lines.length && lines[i].trim().length === 0) {
277+
position = i;
278+
i++;
279+
}
280+
281+
// Preserve current behavior when there are no trailing blank lines or when
282+
// blanks are followed by content (e.g. before a new heading).
283+
if (position === sectionEndIndex || i !== lines.length) {
284+
return sectionEndIndex;
285+
}
286+
287+
// split("\n") keeps a trailing empty string when content ends in "\n".
288+
// We keep one trailing slot so the next insertion preserves capture spacing
289+
// without introducing an extra blank line before the inserted text.
290+
if (body.endsWith("\n")) {
291+
return Math.max(sectionEndIndex, position - 1);
292+
}
293+
294+
return position;
295+
}
296+
266297
private async insertAfterHandler(formatted: string) {
267298
// Use centralized location formatting for selector strings
268299
const targetString: string = await this.formatLocationString(
@@ -299,7 +330,11 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
299330
!!this.choice.insertAfter.considerSubsections,
300331
);
301332

302-
targetPosition = endOfSectionIndex ?? fileContentLines.length - 1;
333+
targetPosition = this.findInsertAfterPositionAtSectionEnd(
334+
fileContentLines,
335+
endOfSectionIndex ?? fileContentLines.length - 1,
336+
this.fileContent,
337+
);
303338
} else {
304339
const blankLineMode =
305340
this.choice.insertAfter?.blankLineAfterMatchMode ?? "auto";
@@ -431,7 +466,11 @@ export class CaptureChoiceFormatter extends CompleteFormatter {
431466
!!this.choice.insertAfter.considerSubsections,
432467
);
433468

434-
targetPosition = endOfSectionIndex ?? fileContentLines.length - 1;
469+
targetPosition = this.findInsertAfterPositionAtSectionEnd(
470+
fileContentLines,
471+
endOfSectionIndex ?? fileContentLines.length - 1,
472+
this.fileContent,
473+
);
435474
}
436475

437476
const newFileContent = this.insertTextAfterPositionInBody(

0 commit comments

Comments
 (0)