diff --git a/CHANGELOG.md b/CHANGELOG.md index 71be085..d38234b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ All notable changes to AS Notes will be documented here. - Feature: Detects conflicting Markdown Inline Editor extensions and offers to disable them. - Feature: Outliner mode awareness -- bullet markers and checkbox syntax always remain visible when outliner mode is active. +## [2.3.1] - 2026-03-31 + +- Feature: Improved page / wikilink rename merge behaviours. +- Feature: Mermaid / LaTeX rendering in published HTML (static site rendering). + +## [2.3.0] - 2026-03-28 + +- Feature: Integration of inline markdown editing. + ## [2.2.9] - 2026-03-24 - Feature: Improved default themes for static HTML publishing. diff --git a/TECHNICAL.md b/TECHNICAL.md index f14c5ce..46cb1cd 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -1096,9 +1096,11 @@ The `sortText` prefix ensures pages always appear before aliases in the list. Bo **Front matter suppression:** `isLineInsideFrontMatter()` checks whether the cursor is between the first two `---` lines. If so, no completions are returned — front matter aliases are plain strings, not wikilinks. +**Code suppression:** `isPositionInsideCode()` is also used to suppress wikilink editor interaction inside inline code spans and fenced code blocks. This includes ordinary standalone fences and bullet-owned fenced blocks opened directly on outliner bullet lines such as `- \`\`\``. When the cursor is inside code, wikilinks are treated as plain text rather than active editor affordances. + All five pure functions — `findInnermostOpenBracket()`, `findMatchingCloseBracket()`, `isLineInsideFrontMatter()`, `isPositionInsideCode()`, and `hasNewCompleteWikilink()` — live in `CompletionUtils.ts` with no VS Code dependency, and are fully unit-tested. -**Code block detection:** `isPositionInsideCode(lines, lineIndex, charIndex)` scans lines 0 through `lineIndex` tracking fenced code block open/close state (supports both `` ` `` and `~` fences, respects fence length — a closing fence must use the same character and at least as many markers as the opener). If a fence is still open at `lineIndex`, the position is inside a code block. Separately checks inline code spans (`` ` ``) on the target line. +**Code block detection:** `isPositionInsideCode(lines, lineIndex, charIndex)` scans lines 0 through `lineIndex` tracking fenced code block open/close state (supports both `` ` `` and `~` fences, respects fence length — a closing fence must use the same character and at least as many markers as the opener). The fence scanner recognises both standalone fence lines and bullet-owned fence opener lines used by outliner blocks, so a fence opened with `- \`\`\`` suppresses wikilink features until its matching closing fence line. If a fence is still open at `lineIndex`, the position is inside a code block. Separately checks inline code spans (`` ` ``) on the target line. ### Caching strategy @@ -1131,6 +1133,18 @@ To handle this, a separate `onDidChangeTextDocument` listener in `extension.ts` The listener only fires on deletions (where `rangeLength > 0` and `text` is empty), not on forward typing — forward typing either keeps the existing session alive or opens a new one via the `[` trigger character. +### Wikilink interaction suppression inside code + +The same code-context rule now applies across the main wikilink editor surfaces, not just slash commands: + +- **Completion** — `WikilinkCompletionProvider` returns no completions when the cursor is inside inline code or a fenced code block. +- **Hover** — `WikilinkHoverProvider` returns no tooltip when the hovered position is inside code. +- **Document links** — `WikilinkDocumentLinkProvider` skips link segments whose start offset falls inside code. +- **Decorations** — `WikilinkDecorationManager` skips decorating wikilink segments inside inline code or fenced code blocks. +- **Rename tracking** — `WikilinkRenameTracker` does not create pending rename state for edits inside code, and ignores live wikilinks inside code during rename detection. + +This applies in both normal markdown flow and outliner-owned fenced code blocks. + ### Completion and rename tracking interaction When the user types inside a wikilink, two things happen on every keystroke: @@ -2744,24 +2758,44 @@ Outliner mode (`as-notes.outlinerMode` setting) turns the markdown editor into a | `isOnBulletLine(lineText)` | Returns `true` for lines matching `/^\s*- /` | | `getOutlinerEnterInsert(lineText)` | Returns the `\n{indent}-` or `\n{indent}- [ ]` string to insert on Enter | | `isCodeFenceOpen(lineText)` | Returns `true` when a bullet line ends with `` ``` `` (optionally + language) | +| `getFenceTokenCursorZone(lineText, cursorCharacter)` | Classifies the cursor as `before`, `inside`, or `after` the first `` ``` `` token on the line | +| `isOutlinerBackspaceMergeCandidate(lineText, cursorCharacter)` | Returns `true` when the cursor is at the end of an empty bullet shell such as `-` or `- [ ]` | +| `getOutlinerBackspaceTargetLine(lines, lineIndex)` | Returns the previous sibling bullet or nearest ancestor bullet that an empty shell should collapse into | +| `getOutlinerFenceContentBoundary(lines, lineIndex)` | Returns the minimum allowed indent column for content lines inside a bullet-owned fence, else `null` | +| `canJoinOutlinerFenceContentWithPreviousLine(lines, lineIndex)` | Returns `true` when a column-0 Backspace may join the current fence-content line into the previous line within the same bullet-owned fence | +| `getOutlinerFenceVerticalMoveTarget(lineText, preferredCharacter, contentBoundary)` | Pads short fence-content lines when needed and returns a cursor column clamped to the fence boundary | +| `isOutlinerFenceBackspaceBlocked(lineText, cursorCharacter, contentBoundary)` | Returns `true` when Backspace would move fence content left of its allowed boundary | +| `shiftOutlinerFenceContentLine(lineText, tabSize, direction, contentBoundary)` | Indents or outdents a fence-content line while clamping outdent at the fence boundary | | `getCodeFenceEnterInsert(lineText)` | Returns the code block skeleton to insert on Enter (indented +2 past bullet) | | `isStandaloneCodeFenceOpen(lineText)` | Returns `true` for non-bullet lines matching opening `` ``` `` (optionally + language) | | `getStandaloneCodeFenceEnterInsert(lineText)` | Returns the code block skeleton at same indent (no +2 offset) | | `isClosingCodeFenceLine(lineText)` | Returns `true` for non-bullet bare `` ``` `` lines (no language identifier) | -| `getClosingFenceBulletInsert(lines, lineIndex)` | Scans upward from closing fence; returns new bullet insert if inside a bullet code block, else `null` | +| `getBulletCodeFenceContext(lines, lineIndex)` | Returns bullet-owned fence context for the current line when the cursor is on an opening fence, its content, or its closing fence | +| `getBulletCodeFenceEnterInsert(lines, lineIndex)` | Returns the correct Enter insertion for a bullet fence opener: full skeleton when unclosed, continuation line when already balanced, and accepts the caret positioned at the opening fence start | +| `getClosingFenceBulletInsert(lines, lineIndex)` | Returns new bullet insert if the closing fence belongs to a bullet code block, else `null` | | `isCodeFenceUnbalanced(lines, lineIndex)` | Returns `true` when the standalone fence at `lineIndex` has no matching pair at the same indent | +| `isInsideBulletCodeFence(lines, lineIndex)` | Returns `true` when the current line is within a bullet-owned fenced code block, including the opening and closing fence lines | +| `getOwningOutlinerBranchLine(lines, lineIndex)` | Returns the owning bullet branch root for any line inside an outliner-owned block, else `null` | +| `getOutlinerBranchActionLine(lines, lineIndex)` | Returns the branch root when Tab / Shift+Tab should move the owning branch from the current line, else `null` | +| `getOutlinerFirstChildLine(lines, lineIndex)` | Returns the first descendant bullet line in the branch, or `null` when the bullet has no children | | `getMaxOutlinerIndent(lines, lineIndex, tabSize)` | Returns the maximum indent allowed for a bullet — at most one tab stop past the nearest bullet above | | `formatOutlinerPaste(lineText, cursorChar, clipboardText)` | Formats multi-line clipboard text as indented bullets | +| `formatOutlinerFencePaste(contentBoundary, clipboardText)` | Rebases pasted fence content so its leftmost non-blank line starts at the boundary while preserving relative indentation | | `toggleOutlinerTodoLine(lineText)` | 3-state cycle: plain bullet → unchecked → done → plain bullet | ### Context keys -Three context keys are maintained in `activate()` (before full-mode setup, as outliner mode requires no index): +Six context keys are maintained in `activate()` (before full-mode setup, as outliner mode requires no index): - **`as-notes.outlinerMode`** — mirrors the `as-notes.outlinerMode` setting value. Synced on activation and on `onDidChangeConfiguration`. -- **`as-notes.onBulletLine`** — `true` when any cursor's active line matches `/^\s*- /`. Updated on `onDidChangeTextEditorSelection` and `onDidChangeActiveTextEditor`. +- **`as-notes.onBulletLine`** — `true` when any cursor's active line matches `/^\s*- /`, excluding lines inside bullet-owned fence content. +- **`as-notes.onOutlinerBranchLine`** — `true` when any cursor's active line can resolve to a branch move target via `getOutlinerBranchActionLine(...)`, excluding lines inside bullet-owned fence content. +- **`as-notes.onOutlinerBackspaceMergePoint`** — `true` when there is a single empty cursor selection at the end of an empty outliner bullet shell that can be structurally collapsed, excluding fence-content lines. +- **`as-notes.insideOutlinerFenceContent`** — `true` when any cursor's active line is a content or blank line inside a bullet-owned fenced code block, excluding the opening and closing fence lines. - **`as-notes.onCodeFenceLine`** — `true` when any cursor's active line is a non-bullet code fence (opening or closing). Updated alongside `onBulletLine`. +These context keys are refreshed not only on selection/editor changes but also on active-document text changes, so keybindings that depend on transient line shapes (such as Backspace after deleting down to `-`) become eligible immediately after the preceding edit. + This combination allows keybindings to fire only when in outliner mode AND on a relevant line, preserving normal behaviour elsewhere. ### Enter — bullet continuation @@ -2770,14 +2804,19 @@ Keybinding: `Enter` when `editorLangId == markdown && as-notes.outlinerMode && a Command `as-notes.outlinerEnter` for each cursor (in priority order): -1. **Bullet code fence open** — `isCodeFenceOpen` returns `true`: inserts code block skeleton indented +2 past bullet (see below). -2. **Bullet line** — deletes from cursor to end of line, inserts `getOutlinerEnterInsert(lineText)`. Text after cursor is pushed to the new bullet. +1. **Bullet code fence open, cursor at the fence start or later** — `isCodeFenceOpen` returns `true` and `getBulletCodeFenceEnterInsert(...)` returns a value: inserts that result, which either opens a new bullet-owned skeleton or continues into the existing balanced fence block. This includes the caret position immediately before the first backtick of the opener, so pressing Enter there no longer falls through to generic outliner splitting and strip the opening fence token. +2. **Bullet with existing children, cursor at end of line** — inserts a new first child before the current first descendant bullet. The new line reuses the first child's indentation and keeps the parent's bullet vs unchecked-todo shape. +3. **Bullet line fallback** — deletes from cursor to end of line, inserts `getOutlinerEnterInsert(lineText)`. Text after cursor is pushed to the new bullet. Standalone and closing code fences are handled by the separate `codeFenceEnter` command (see below). ### Enter — code fence -When a bullet line ends with `` ``` `` or `` ```language ``, `isCodeFenceOpen` returns `true` and the command inserts a code block skeleton instead of a new bullet: +When a bullet line ends with `` ``` `` or `` ```language ``, `isCodeFenceOpen` returns `true` and the command only takes the fence-specific path when the cursor is on or after the fence token. If the cursor is still before the opening backticks, Enter falls back to normal outliner splitting so the trailing fence text moves to the next bullet. + +When the fence-specific path is active, the command uses bullet-fence context to choose between two behaviours: + +1. **Unclosed bullet fence opener** — inserts a full code block skeleton: ``` - ```javascript ← original line (unchanged) @@ -2785,6 +2824,8 @@ When a bullet line ends with `` ``` `` or `` ```language ``, `isCodeFenceOpen` r ``` ← closing fence at same content indent ``` +1. **Already-balanced bullet fence opener** — inserts only a continuation line at the bullet content indent so Enter moves into the existing code block without inserting a duplicate closing fence. + The content inside the fence is indented 2 spaces past the bullet marker. This offset is hardcoded (not derived from editor tab size) to match standard markdown list continuation indent. For standalone (non-bullet) opening fences with a language identifier, `isStandaloneCodeFenceOpen` returns `true` and the skeleton is inserted at the same indentation as the opening fence (no +2 offset): @@ -2795,7 +2836,9 @@ For standalone (non-bullet) opening fences with a language identifier, `isStanda ``` ← closing fence at same indent ``` -After the edit, the cursor is repositioned from the end of the closing fence to the blank content line via `editor.selections` assignment in the `.then()` callback. +When a standalone fence opener lives inside an outliner-owned continuation block, the post-Enter cursor position is now clamped to at least the continuation boundary (`rootIndent + 2`). This prevents the cursor from landing left of the outliner continuation boundary when a later standalone fence is started in the same branch. + +After custom Enter edits, the extension now assigns explicit cursor targets per selection rather than relying on VS Code's default post-edit cursor placement. That keeps the cursor on the inserted continuation or bullet line after both fence skeleton insertion and closing-fence bullet continuation. ### Enter — code fence completion @@ -2805,9 +2848,10 @@ Keybinding: `Enter` when `editorLangId == markdown && as-notes.onCodeFenceLine & For each cursor (in priority order): -1. **Closing fence of a bullet code block** — `isClosingCodeFenceLine` returns `true` and `getClosingFenceBulletInsert` returns a result. In outliner mode, inserts a new bullet at the parent's indentation. Outside outliner mode, inserts a plain newline. -2. **Unbalanced standalone fence** — `isStandaloneCodeFenceOpen` returns `true` and `isCodeFenceUnbalanced` returns `true`: inserts the closing fence skeleton and positions cursor inside. -3. **Balanced standalone fence** — `isStandaloneCodeFenceOpen` returns `true` but `isCodeFenceUnbalanced` returns `false` (the fence already has a matching closer at the same indent): inserts a plain newline. +1. **Cursor before fence token** — performs a plain line split at the cursor so Enter behaves like normal text editing rather than fence completion. +2. **Closing fence of a bullet code block** — `isClosingCodeFenceLine` returns `true` and `getClosingFenceBulletInsert` returns a result based on `getBulletCodeFenceContext(...)`. In outliner mode, inserts a new bullet at the parent's indentation. Outside outliner mode, inserts a plain newline. +3. **Unbalanced standalone fence** — `isStandaloneCodeFenceOpen` returns `true` and `isCodeFenceUnbalanced` returns `true`: inserts the closing fence skeleton and positions cursor inside. +4. **Balanced standalone fence** — `isStandaloneCodeFenceOpen` returns `true` but `isCodeFenceUnbalanced` returns `false` (the fence already has a matching closer at the same indent): inserts a plain newline. #### Fence balance detection @@ -2819,26 +2863,115 @@ For each cursor (in priority order): Fences at different indent levels are never paired. Bullet-prefixed fences are excluded. +Indented fence lines that belong to an existing bullet-owned fenced block are also excluded from the standalone pairing heuristic. This prevents a later standalone fence in the same outliner branch from being misclassified as already balanced just because a bullet-owned fence closed earlier at the same indent. + ### Tab / Shift+Tab — indent and outdent -Keybinding: `Tab` / `Shift+Tab` when `editorLangId == markdown && as-notes.outlinerMode && as-notes.onBulletLine`. +Keybinding: `Tab` / `Shift+Tab` when `editorLangId == markdown && as-notes.outlinerMode && (as-notes.onOutlinerBranchLine || as-notes.insideOutlinerFenceContent)`. + +`Tab` and `Shift+Tab` are handled by explicit branch-aware edits rather than delegating to VS Code's generic indent commands, and can now be triggered from any line that still belongs to an outliner-owned branch. -`Tab` delegates to `editor.action.indentLines` but only when the resulting indent would not exceed one tab stop past the nearest bullet above. `getMaxOutlinerIndent(lines, lineIndex, tabSize)` scans upward for the closest bullet and returns its indent + tabSize. If no bullet exists above, 0 is returned (root level only). When any selection's line would exceed the maximum, the indent is suppressed entirely. +When **all** active selections are on bullet-owned fence-content lines, the commands take a separate fence-content path instead of moving the owning outliner branch: -`Shift+Tab` always delegates to `editor.action.outdentLines` with no guard — reducing indent is always valid. +- `Tab` indents each selected fence-content line by one tab stop +- `Shift+Tab` outdents each selected fence-content line, but clamps at the fence content boundary so the line cannot move left of the aligned backticks +- these edits operate on the selected content lines only; they do not move the parent bullet branch + +Fence-content lines no longer advertise themselves as generic outliner bullet or branch lines. This prevents code lines such as `- item` inside fenced code blocks from triggering bullet-aware outliner editing. + +`applyOutlinerBranchMove(...)` first resolves each selected line through `getOutlinerBranchActionLine(...)`: + +- bullet lines resolve to themselves +- continuation lines outside fenced code resolve to their owning bullet branch root +- fence opener / closer structural lines resolve to their owning bullet branch root +- actual code-content lines inside a bullet-owned fenced block resolve to `null`, so Tab / Shift+Tab falls back to normal editor indentation for those lines + +`OutlinerService` computes the selected branch range from the bullet line down to the end of its descendant block. The moved range includes: + +- descendant bullet lines deeper than the branch root +- non-bullet continuation lines that belong to the branch, such as paragraphs, fenced code, tables, blockquotes, and indented images + +Branch ownership uses **content-indent semantics** rather than a naive "everything until the next bullet" rule: + +- branch root indent = indentation before `-` +- continuation indent = branch root indent + 2 +- a non-bullet line belongs to the branch only when its indentation is at least the continuation indent +- blank lines are neutral and only remain part of the branch when the next significant line still satisfies the continuation rule +- fenced blocks are stateful regions: once an included standalone fence starts, its internal lines and blank lines remain part of the branch until the closing fence + +`Tab` applies a **push** to the whole branch: the root bullet and everything in its descendant block move one tab stop deeper together. The one-level-deeper guard still applies, but only to the branch root. `canIndentOutlinerBranch(...)` uses `getMaxOutlinerIndent(...)` for that check. + +`Shift+Tab` applies a **drag** to the whole branch: the root bullet and everything in its descendant block move one tab stop shallower together, clamped at root indentation. This prevents descendants from being left more than one indentation level deeper than their parent after an outdent. + +This means structural continuation lines such as fence delimiters still move the owning branch, while actual code-content lines inside fenced code retain normal editor indentation behaviour. On non-bullet lines Tab retains normal VS Code behaviour with no extra logic. -### Paste — multi-line bullet conversion +### Backspace — collapse empty bullet shell and guard fence content + +Keybinding: `Backspace` when `editorLangId == markdown && as-notes.outlinerMode && (as-notes.onOutlinerBackspaceMergePoint || as-notes.insideOutlinerFenceContent) && !editorReadonly && editorHasSelection == false && !suggestWidgetVisible && !inlineSuggestionVisible`. -Keybinding: `Ctrl+V` / `Cmd+V` when `editorLangId == markdown && as-notes.outlinerMode && as-notes.onBulletLine && !editorReadonly`. +`as-notes.outlinerBackspace` now handles two narrow outliner-only cases before falling back to VS Code's normal `deleteLeft` behaviour. + +**Case 1 — fence-content left-boundary guard** + +When the active line is inside a bullet-owned fenced code block, `getOutlinerFenceContentBoundary(...)` returns the content boundary column (`bulletIndent + 2`). The command now handles fence content in two stages: + +1. **Column-0 join** — if the cursor is at column `0` and `canJoinOutlinerFenceContentWithPreviousLine(...)` returns `true`, the command falls through to normal `deleteLeft`, which joins the current line into the previous fence-content line inside the same bullet-owned fence. +2. **Boundary guard** — otherwise, `isOutlinerFenceBackspaceBlocked(...)` blocks Backspace when either of the following is true: + +- the cursor is at column `0` (prevents joining the fence-content line with the previous line) +- the cursor is at or before the boundary and the line's current indent is already at or below that boundary + +If the guard blocks, the command becomes a no-op. If the cursor is past the boundary, or the line still has excess indent above the boundary, the command falls through to normal `deleteLeft`. + +This ensures text inside bullet-owned fenced code blocks cannot be moved left of the aligned fence-content boundary by Backspace. + +**Case 2 — empty bullet structural collapse** + +The structural merge path still only runs when all of the following are true: + +- there is exactly one empty cursor selection +- the current line is an empty bullet shell such as `-`, `-`, `- [ ]`, or `- [ ]` +- the cursor is at the shell's content-start/end position (the same position a second Backspace would normally keep deleting left from) + +When triggered: + +1. `getOutlinerBackspaceTargetLine(...)` scans upward for the first bullet whose indentation is less than or equal to the current shell's indentation. +2. That means the merge target is the previous sibling when one exists, otherwise the nearest ancestor bullet. +3. The current empty shell line is deleted. +4. The cursor is placed at the end of the target bullet line. + +If no such structural predecessor exists, the command falls back to VS Code's normal `deleteLeft` behaviour. + +This first implementation does **not** jump to the end of the target subtree; it deliberately lands at the end of the target bullet line only. + +### Paste — multi-line bullet conversion and fence-content rebasing + +Keybinding: `Ctrl+V` / `Cmd+V` when `editorLangId == markdown && as-notes.outlinerMode && (as-notes.onBulletLine || as-notes.insideOutlinerFenceContent) && !editorReadonly`. Command `as-notes.outlinerPaste`: 1. Reads clipboard via `vscode.env.clipboard.readText()`. -2. Calls `formatOutlinerPaste(lineText, cursorCharacter, clipboardText)`. -3. If result is `null` (single-line paste or all-empty lines), falls through to `editor.action.clipboardPasteAction`. -4. Otherwise, replaces the entire current line with the formatted bullets. +2. If the active line is fence content inside a bullet-owned code block and the paste is a multi-line paste, a non-empty selection replacement, or a paste into a blank fence-content line, it calls `formatOutlinerFencePaste(contentBoundary, clipboardText)`. +3. For blank fence-content lines with an empty selection, the command replaces the **entire line** rather than the empty selection, so the first pasted row does not inherit the line's existing indentation in addition to the rebased fence indentation. +4. Otherwise it calls `formatOutlinerPaste(lineText, cursorCharacter, clipboardText)`. +5. If the bullet-format result is `null` (single-line paste or all-empty lines), falls through to `editor.action.clipboardPasteAction`. +6. Otherwise, replaces the entire current bullet line with the formatted bullets. + +When the active line is inside bullet-owned fence content and the paste does **not** meet the fence-rebasing criteria (for example, a simple single-line insertion into a non-blank line), the command falls through to VS Code's normal paste behaviour rather than trying to treat the line as an outliner bullet. + +### Arrow Up / Down — fence-content cursor clamp + +Keybinding: `Up` / `Down` when `editorLangId == markdown && as-notes.outlinerMode && as-notes.insideOutlinerFenceContent && editorHasSelection == false && !suggestWidgetVisible && !inlineSuggestionVisible`. + +`as-notes.outlinerFenceArrowUp` and `as-notes.outlinerFenceArrowDown` provide a narrow cursor-movement invariant for bullet-owned fence content: + +- when the next/previous line is still fence content, the cursor moves vertically while preserving the preferred column as much as possible but clamps it to at least the fence boundary +- if the target fence-content line is shorter than the boundary, it is padded with spaces so the cursor can legally land at the boundary column +- when movement would leave the fence-content region, the commands fall through to VS Code's normal cursor movement + +The same boundary-padding rule is also reused on empty selection changes for blank bullet-owned fence-content lines. If the user clicks onto a blank or whitespace-only fence-content line left of the boundary, the line is padded as needed and the caret is immediately clamped back to the boundary instead of being left at column `0`. **`formatOutlinerPaste` rules:** @@ -2849,6 +2982,14 @@ Command `as-notes.outlinerPaste`: - Text before cursor on the current line is preserved; text after cursor is appended to the last pasted line. - Single-line clipboard text: no conversion (returns `null`). +**`formatOutlinerFencePaste` rules:** + +- CRLF normalised to LF. +- Blank pasted lines remain blank. +- Finds the minimum leading indent across non-blank pasted lines. +- Re-bases the pasted block so that minimum indent lands on the fence content boundary. +- Preserves relative indentation inside the pasted block instead of flattening all lines to the same column. + ### Todo toggle in outliner mode When `as-notes.outlinerMode` is enabled and the line `isOnBulletLine`, the `as-notes.toggleTodo` command uses `toggleOutlinerTodoLine` instead of the default `toggleTodoLine`. The outliner cycle preserves the `-` prefix: diff --git a/vs-code-extension/package.json b/vs-code-extension/package.json index ac95e15..cf7f3c3 100644 --- a/vs-code-extension/package.json +++ b/vs-code-extension/package.json @@ -252,18 +252,33 @@ { "command": "as-notes.outlinerIndent", "key": "tab", - "when": "editorLangId == markdown && as-notes.outlinerMode && as-notes.onBulletLine" + "when": "editorLangId == markdown && as-notes.outlinerMode && (as-notes.onOutlinerBranchLine || as-notes.insideOutlinerFenceContent)" }, { "command": "as-notes.outlinerOutdent", "key": "shift+tab", - "when": "editorLangId == markdown && as-notes.outlinerMode && as-notes.onBulletLine" + "when": "editorLangId == markdown && as-notes.outlinerMode && (as-notes.onOutlinerBranchLine || as-notes.insideOutlinerFenceContent)" }, { "command": "as-notes.outlinerPaste", "key": "ctrl+v", "mac": "cmd+v", - "when": "editorLangId == markdown && as-notes.outlinerMode && as-notes.onBulletLine && !editorReadonly" + "when": "editorLangId == markdown && as-notes.outlinerMode && (as-notes.onBulletLine || as-notes.insideOutlinerFenceContent) && !editorReadonly" + }, + { + "command": "as-notes.outlinerBackspace", + "key": "backspace", + "when": "editorLangId == markdown && as-notes.outlinerMode && (as-notes.onOutlinerBackspaceMergePoint || as-notes.insideOutlinerFenceContent) && !editorReadonly && editorHasSelection == false && !suggestWidgetVisible && !inlineSuggestionVisible" + }, + { + "command": "as-notes.outlinerFenceArrowDown", + "key": "down", + "when": "editorLangId == markdown && as-notes.outlinerMode && as-notes.insideOutlinerFenceContent && editorHasSelection == false && !suggestWidgetVisible && !inlineSuggestionVisible" + }, + { + "command": "as-notes.outlinerFenceArrowUp", + "key": "up", + "when": "editorLangId == markdown && as-notes.outlinerMode && as-notes.insideOutlinerFenceContent && editorHasSelection == false && !suggestWidgetVisible && !inlineSuggestionVisible" } ], "configuration": { diff --git a/vs-code-extension/src/CompletionUtils.ts b/vs-code-extension/src/CompletionUtils.ts index 94dbb05..53c5bbb 100644 --- a/vs-code-extension/src/CompletionUtils.ts +++ b/vs-code-extension/src/CompletionUtils.ts @@ -107,28 +107,34 @@ export function isLineInsideFrontMatter(lines: string[], lineIndex: number): boo */ export function isPositionInsideCode(lines: string[], lineIndex: number, charIndex: number): boolean { // Check fenced code block — scan up to lineIndex tracking open/close state - const fencePattern = /^(\s*(`{3,}|~{3,}))/; + const standaloneFencePattern = /^\s*(`{3,}|~{3,})/; + const bulletOwnedFencePattern = /^\s*-\s(?:\[[ xX]\]\s)?(?:.*\s)?(`{3,}|~{3,})\S*\s*$/; let inFence = false; let fenceChar = ''; let fenceLen = 0; for (let i = 0; i <= lineIndex; i++) { - const m = fencePattern.exec(lines[i]); - if (m) { - const char = m[2][0]; // ` or ~ - const len = m[2].length; - if (!inFence) { - // Opening fence - inFence = true; - fenceChar = char; - fenceLen = len; - } else if (char === fenceChar && len >= fenceLen) { - // Closing fence — same char, at least as many markers - inFence = false; - fenceChar = ''; - fenceLen = 0; - } - // Otherwise it's a different fence type or shorter — ignored + const line = lines[i] ?? ''; + const standaloneMatch = standaloneFencePattern.exec(line); + const bulletOwnedMatch = bulletOwnedFencePattern.exec(line); + const marker = standaloneMatch?.[1] ?? bulletOwnedMatch?.[1]; + if (!marker) { + continue; + } + + const char = marker[0]; // ` or ~ + const len = marker.length; + if (!inFence) { + // Opening fence + inFence = true; + fenceChar = char; + fenceLen = len; + } else if (char === fenceChar && len >= fenceLen) { + // Closing fence — same char, at least as many markers + inFence = false; + fenceChar = ''; + fenceLen = 0; } + // Otherwise it's a different fence type or shorter — ignored } // If we're still inside a fence at lineIndex, cursor is in a code block. // The opening fence line itself is part of the block, but content starts diff --git a/vs-code-extension/src/OutlinerService.ts b/vs-code-extension/src/OutlinerService.ts index daacf70..65154aa 100644 --- a/vs-code-extension/src/OutlinerService.ts +++ b/vs-code-extension/src/OutlinerService.ts @@ -21,6 +21,9 @@ const TODO_LINE = /^(\s*)- \[[ xX]\] /; /** Captures the leading indentation of a bullet line. */ const BULLET_INDENT = /^(\s*)- /; +/** Captures leading whitespace for any line. */ +const LEADING_WHITESPACE = /^(\s*)/; + /** Matches a done todo: optional indent, `- [x]` or `- [X]`, then content. */ const DONE_TODO = /^(\s*)- \[(?:x|X)\] ?(.*)/; @@ -30,12 +33,20 @@ const UNCHECKED_TODO = /^(\s*)- \[ \] ?(.*)/; /** Matches a plain bullet (no checkbox): optional indent, `- `, then content. */ const PLAIN_BULLET = /^(\s*)- (.*)/; +/** Matches an empty plain bullet shell, with or without the trailing space. */ +const EMPTY_PLAIN_BULLET = /^(\s*)-(?: )?$/; + +/** Matches an empty todo bullet shell, with or without the trailing space. */ +const EMPTY_TODO_BULLET = /^(\s*)- \[[ xX]\](?: )?$/; + /** * Matches a bullet line whose content ends with an opening code fence: * optional indent, `- `, optional text, triple backticks, optional language, optional trailing whitespace. */ const CODE_FENCE_OPEN = /^(\s*)- .*```(\w*)\s*$/; +export type FenceTokenCursorZone = 'before' | 'inside' | 'after' | 'none'; + // ── Public API ───────────────────────────────────────────────────────────── /** @@ -59,9 +70,9 @@ export function isOnBulletLine(lineText: string): boolean { * cursor in VS Code naturally splits the current line and positions the new * bullet below. */ -export function getOutlinerEnterInsert(lineText: string): string { +export function getOutlinerEnterInsert(lineText: string, indentOverride?: string): string { const indentMatch = lineText.match(BULLET_INDENT); - const indent = indentMatch?.[1] ?? ''; + const indent = indentOverride ?? indentMatch?.[1] ?? ''; if (TODO_LINE.test(lineText)) { return `\n${indent}- [ ] `; @@ -109,6 +120,42 @@ export function toggleOutlinerTodoLine(lineText: string): string { return lineText; } +export function isOutlinerBackspaceMergeCandidate( + lineText: string, + cursorCharacter: number, +): boolean { + if (EMPTY_PLAIN_BULLET.test(lineText) || EMPTY_TODO_BULLET.test(lineText)) { + return cursorCharacter === lineText.length; + } + + return false; +} + +export function getOutlinerBackspaceTargetLine( + lines: string[], + lineIndex: number, +): number | null { + // Strip trailing \r so regexes anchored with $ work on \r\n line endings. + const lineText = lines[lineIndex]?.replace(/\r$/, ''); + if (!lineText || !isOutlinerBackspaceMergeCandidate(lineText, lineText.length)) { + return null; + } + + const currentIndent = getLineIndent(lineText); + for (let i = lineIndex - 1; i >= 0; i--) { + const candidate = lines[i]; + if (!candidate || !BULLET_LINE.test(candidate)) { + continue; + } + + if (getLineIndent(candidate) <= currentIndent) { + return i; + } + } + + return null; +} + // ── Code fence detection ─────────────────────────────────────────────────── /** @@ -121,6 +168,26 @@ export function isCodeFenceOpen(lineText: string): boolean { return CODE_FENCE_OPEN.test(lineText); } +export function getFenceTokenCursorZone( + lineText: string, + cursorCharacter: number, +): FenceTokenCursorZone { + const fenceStart = lineText.indexOf('```'); + if (fenceStart === -1) { + return 'none'; + } + + if (cursorCharacter <= fenceStart) { + return 'before'; + } + + if (cursorCharacter < fenceStart + 3) { + return 'inside'; + } + + return 'after'; +} + /** * Returns the text to insert when Enter is pressed on a bullet line that ends * with an opening code fence. @@ -133,10 +200,124 @@ export function isCodeFenceOpen(lineText: string): boolean { * (4 spaces indent + 2 spaces past hyphen = 6 spaces for continuation content) */ export function getCodeFenceEnterInsert(lineText: string): string { + const contentIndent = getBulletContentIndent(lineText); + return `\n${contentIndent}\n${contentIndent}\`\`\``; +} + +export interface BulletCodeFenceContext { + readonly openerLine: number; + readonly rootIndent: number; + readonly contentIndent: number; + readonly isTodo: boolean; +} + +function getBulletContentIndent(lineText: string): string { const match = lineText.match(BULLET_INDENT); const bulletIndent = match?.[1] ?? ''; - // Content inside a list item is indented 2 spaces past the `- ` marker - const contentIndent = bulletIndent + ' '; + return bulletIndent + ' '; +} + +function getBulletInsertFromContext(context: BulletCodeFenceContext): string { + const indent = ' '.repeat(context.rootIndent); + return context.isTodo ? `\n${indent}- [ ] ` : `\n${indent}- `; +} + +function hasClosingFenceForBulletCodeFence(lines: string[], openerLineIndex: number): boolean { + const openerLine = lines[openerLineIndex]; + if (!openerLine || !CODE_FENCE_OPEN.test(openerLine)) { + return false; + } + + const rootIndent = getLineIndent(openerLine); + const continuationIndent = rootIndent + 2; + + for (let i = openerLineIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const indent = getLineIndent(line); + + if (isClosingCodeFenceLine(line) && indent >= continuationIndent) { + return true; + } + + if (BULLET_LINE.test(line) && indent <= rootIndent) { + return false; + } + + if (!BULLET_LINE.test(line) && !isBlankLine(line) && indent < continuationIndent) { + return false; + } + } + + return false; +} + +export function getBulletCodeFenceContext( + lines: string[], + lineIndex: number, +): BulletCodeFenceContext | null { + if (lineIndex < 0 || lineIndex >= lines.length) { + return null; + } + + let activeContext: BulletCodeFenceContext | null = null; + let contextAtLine: BulletCodeFenceContext | null = null; + + for (let i = 0; i <= lineIndex; i++) { + const line = lines[i]; + + if (!activeContext && CODE_FENCE_OPEN.test(line)) { + const rootIndent = getLineIndent(line); + activeContext = { + openerLine: i, + rootIndent, + contentIndent: rootIndent + 2, + isTodo: TODO_LINE.test(line), + }; + } + + if (i === lineIndex) { + contextAtLine = activeContext; + } + + if ( + activeContext + && i !== activeContext.openerLine + && isClosingCodeFenceLine(line) + && getLineIndent(line) >= activeContext.contentIndent + ) { + activeContext = null; + } + } + + return contextAtLine; +} + +export function isInsideBulletCodeFence(lines: string[], lineIndex: number): boolean { + return getBulletCodeFenceContext(lines, lineIndex) !== null; +} + +export function getBulletCodeFenceEnterInsert( + lines: string[], + lineIndex: number, + cursorCharacter?: number, +): string | null { + const lineText = lines[lineIndex]; + if (!lineText || !CODE_FENCE_OPEN.test(lineText)) { + return null; + } + + if (cursorCharacter !== undefined) { + const fenceStart = lineText.indexOf('```'); + if (fenceStart !== -1 && cursorCharacter < fenceStart) { + return null; + } + } + + const contentIndent = getBulletContentIndent(lineText); + if (hasClosingFenceForBulletCodeFence(lines, lineIndex)) { + return `\n${contentIndent}`; + } + return `\n${contentIndent}\n${contentIndent}\`\`\``; } @@ -195,27 +376,16 @@ export function isClosingCodeFenceLine(lineText: string): boolean { * (i.e. the opening fence was standalone or not found). */ export function getClosingFenceBulletInsert(lines: string[], lineIndex: number): string | null { - // Scan upward from the closing fence to find the matching opening fence - for (let i = lineIndex - 1; i >= 0; i--) { - const line = lines[i]; - // Found a bullet-prefixed opening fence - if (CODE_FENCE_OPEN.test(line)) { - // Use the bullet's indent to produce the new bullet - const indentMatch = line.match(BULLET_INDENT); - const indent = indentMatch?.[1] ?? ''; - const isTodo = TODO_LINE.test(line); - if (isTodo) { - return `\n${indent}- [ ] `; - } - return `\n${indent}- `; - } - // Found a standalone opening fence — not a bullet code block - if (STANDALONE_CODE_FENCE_OPEN.test(line)) { - return null; - } + if (!isClosingCodeFenceLine(lines[lineIndex] ?? '')) { + return null; } - // No opening fence found - return null; + + const context = getBulletCodeFenceContext(lines, lineIndex); + if (!context) { + return null; + } + + return getBulletInsertFromContext(context); } // ── Code fence balance detection ─────────────────────────────────────────── @@ -252,6 +422,10 @@ export function isCodeFenceUnbalanced(lines: string[], lineIndex: number): boole return false; } + if (getBulletCodeFenceContext(lines, lineIndex) !== null) { + return false; + } + const indentMatch = targetLine.match(/^(\s*)/); const targetIndent = indentMatch ? indentMatch[1].length : 0; @@ -262,6 +436,7 @@ export function isCodeFenceUnbalanced(lines: string[], lineIndex: number): boole for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (!ANY_STANDALONE_FENCE.test(line) || BULLET_LINE.test(line)) { continue; } + if (getBulletCodeFenceContext(lines, i) !== null) { continue; } const im = line.match(/^(\s*)/); const indent = im ? im[1].length : 0; if (indent !== targetIndent) { continue; } @@ -296,6 +471,7 @@ export function isCodeFenceUnbalanced(lines: string[], lineIndex: number): boole if (i === lineIndex) { continue; } const line = lines[i]; if (ANY_STANDALONE_FENCE.test(line) && !BULLET_LINE.test(line)) { + if (getBulletCodeFenceContext(lines, i) !== null) { continue; } const im = line.match(/^(\s*)/); const indent = im ? im[1].length : 0; if (indent === targetIndent) { @@ -331,6 +507,411 @@ export function getMaxOutlinerIndent( return 0; } +export interface OutlinerBranchRange { + readonly startLine: number; + readonly endLine: number; + readonly rootIndent: number; +} + +export interface OutlinerBranchMoveResult extends OutlinerBranchRange { + readonly lines: string[]; + readonly appliedIndentDelta: number; +} + +function isBlankLine(lineText: string): boolean { + return lineText.trim().length === 0; +} + +function getLineIndent(lineText: string): number { + return lineText.match(LEADING_WHITESPACE)?.[1]?.length ?? 0; +} + +function shiftLineIndent(lineText: string, delta: number): string { + if (delta === 0) { + return lineText; + } + const currentIndent = getLineIndent(lineText); + const nextIndent = Math.max(0, currentIndent + delta); + return `${' '.repeat(nextIndent)}${lineText.slice(currentIndent)}`; +} + +/** + * Returns the contiguous line range belonging to the bullet branch rooted at + * `lineIndex`, including descendant bullets and non-bullet continuation lines. + */ +export function getOutlinerBranchRange( + lines: string[], + lineIndex: number, +): OutlinerBranchRange | null { + const rootLine = lines[lineIndex]; + if (!rootLine || !BULLET_LINE.test(rootLine)) { + return null; + } + + const rootIndent = getLineIndent(rootLine); + const continuationIndent = rootIndent + 2; + let endLine = lineIndex; + let pendingBlankStart: number | null = null; + let insideFence = false; + + for (let i = lineIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const indent = getLineIndent(line); + + if (insideFence) { + endLine = i; + if (ANY_STANDALONE_FENCE.test(line) && !BULLET_LINE.test(line)) { + insideFence = false; + } + continue; + } + + if (isBlankLine(line)) { + if (pendingBlankStart === null) { + pendingBlankStart = i; + } + continue; + } + + let belongsToBranch = false; + + if (BULLET_LINE.test(line)) { + belongsToBranch = indent > rootIndent; + } else { + belongsToBranch = indent >= continuationIndent; + } + + if (!belongsToBranch) { + break; + } + + endLine = i; + if (pendingBlankStart !== null) { + endLine = i; + pendingBlankStart = null; + } + + if (ANY_STANDALONE_FENCE.test(line) && !BULLET_LINE.test(line)) { + insideFence = true; + } + } + + return { + startLine: lineIndex, + endLine, + rootIndent, + }; +} + +export function getOutlinerFirstChildLine( + lines: string[], + lineIndex: number, +): number | null { + const range = getOutlinerBranchRange(lines, lineIndex); + if (!range) { + return null; + } + + for (let i = lineIndex + 1; i <= range.endLine; i++) { + const line = lines[i]; + if (BULLET_LINE.test(line) && getLineIndent(line) > range.rootIndent) { + return i; + } + } + + return null; +} + +export function getOwningOutlinerBranchLine( + lines: string[], + lineIndex: number, +): number | null { + const currentLine = lines[lineIndex]; + if (currentLine === undefined) { + return null; + } + + if (BULLET_LINE.test(currentLine)) { + return lineIndex; + } + + for (let i = lineIndex - 1; i >= 0; i--) { + if (!BULLET_LINE.test(lines[i])) { + continue; + } + const range = getOutlinerBranchRange(lines, i); + if (range && lineIndex >= range.startLine && lineIndex <= range.endLine) { + return i; + } + } + + return null; +} + +export function getOutlinerBranchActionLine( + lines: string[], + lineIndex: number, +): number | null { + const rootLine = getOwningOutlinerBranchLine(lines, lineIndex); + if (rootLine === null) { + return null; + } + + const currentLine = lines[lineIndex] ?? ''; + if (BULLET_LINE.test(currentLine)) { + return rootLine; + } + + const fenceContext = getBulletCodeFenceContext(lines, lineIndex); + if (!fenceContext) { + let insideStandaloneFence = false; + for (let i = rootLine + 1; i <= lineIndex; i++) { + const line = lines[i] ?? ''; + if (ANY_STANDALONE_FENCE.test(line) && !BULLET_LINE.test(line)) { + if (i === lineIndex) { + return rootLine; + } + insideStandaloneFence = !insideStandaloneFence; + continue; + } + } + return insideStandaloneFence ? null : rootLine; + } + + if (lineIndex === fenceContext.openerLine) { + return rootLine; + } + + if (isClosingCodeFenceLine(currentLine) && getLineIndent(currentLine) >= fenceContext.contentIndent) { + return rootLine; + } + + return null; +} + +/** + * Returns true when the selected bullet branch root may be indented one tab + * stop deeper without violating the one-level-deeper guard. + */ +export function canIndentOutlinerBranch( + lines: string[], + lineIndex: number, + tabSize: number, +): boolean { + const range = getOutlinerBranchRange(lines, lineIndex); + if (!range) { + return false; + } + return range.rootIndent + tabSize <= getMaxOutlinerIndent(lines, lineIndex, tabSize); +} + +/** + * Move a bullet branch by one tab stop, preserving the relative indentation of + * all descendants and continuation lines within the branch. + */ +export function moveOutlinerBranch( + lines: string[], + lineIndex: number, + tabSize: number, + direction: 'indent' | 'outdent', +): OutlinerBranchMoveResult | null { + const range = getOutlinerBranchRange(lines, lineIndex); + if (!range) { + return null; + } + + const appliedIndentDelta = direction === 'indent' + ? (canIndentOutlinerBranch(lines, lineIndex, tabSize) ? tabSize : 0) + : (range.rootIndent === 0 ? 0 : -Math.min(tabSize, range.rootIndent)); + + const updatedLines = lines.slice(); + for (let i = range.startLine; i <= range.endLine; i++) { + updatedLines[i] = shiftLineIndent(updatedLines[i], appliedIndentDelta); + } + + return { + ...range, + lines: updatedLines, + appliedIndentDelta, + }; +} + +// ── Fence content boundary guard ─────────────────────────────────────────── + +/** + * Returns the minimum indent column (content boundary) for a content line + * inside a bullet-owned fenced code block. + * + * Returns `null` when: + * - the line is not inside a bullet fence + * - the line is the opener line (bullet + ```) or the closer line (```) + */ +export function getOutlinerFenceContentBoundary( + lines: string[], + lineIndex: number, +): number | null { + const ctx = getBulletCodeFenceContext(lines, lineIndex); + if (!ctx) { + return null; + } + + // Opener line is not a content line + if (lineIndex === ctx.openerLine) { + return null; + } + + // Closer line is not a content line + const line = lines[lineIndex]; + if ( + isClosingCodeFenceLine(line) + && getLineIndent(line) >= ctx.contentIndent + ) { + return null; + } + + return ctx.contentIndent; +} + +/** + * Returns `true` when Backspace should be blocked to prevent content from + * moving left of the fence content boundary. + * + * The guard blocks when: + * - cursor is at column 0 (prevents line-join escape from the fence) + * - cursor is at or before the boundary AND the line's existing indent + * is at or below the boundary (deleting would breach the boundary) + * + * The guard allows when: + * - cursor is past the boundary (normal content editing) + * - line indent exceeds the boundary and cursor is within the excess indent + * (deleting still leaves indent >= boundary) + */ +export function isOutlinerFenceBackspaceBlocked( + lineText: string, + cursorCharacter: number, + contentBoundary: number, +): boolean { + // Cursor past the boundary — always allow + if (cursorCharacter > contentBoundary) { + return false; + } + + // Cursor at column 0 — always block (prevents line join / escape) + if (cursorCharacter === 0) { + return true; + } + + // Cursor is at or before the boundary. + // Allow only when the line's indent exceeds the boundary + // (so deleting one space still keeps indent >= boundary). + const lineIndent = getLineIndent(lineText); + return lineIndent <= contentBoundary; +} + +export function canJoinOutlinerFenceContentWithPreviousLine( + lines: string[], + lineIndex: number, +): boolean { + if (lineIndex <= 0) { + return false; + } + + const currentContext = getBulletCodeFenceContext(lines, lineIndex); + const previousContext = getBulletCodeFenceContext(lines, lineIndex - 1); + if (!currentContext || !previousContext) { + return false; + } + + const currentBoundary = getOutlinerFenceContentBoundary(lines, lineIndex); + const previousBoundary = getOutlinerFenceContentBoundary(lines, lineIndex - 1); + if (currentBoundary === null || previousBoundary === null) { + return false; + } + + return currentContext.openerLine === previousContext.openerLine; +} + +export interface OutlinerFenceContentShiftResult { + lineText: string; + appliedIndentDelta: number; +} + +export function shiftOutlinerFenceContentLine( + lineText: string, + tabSize: number, + direction: 'indent' | 'outdent', + contentBoundary: number, +): OutlinerFenceContentShiftResult { + const currentIndent = getLineIndent(lineText); + const appliedIndentDelta = direction === 'indent' + ? tabSize + : (currentIndent <= contentBoundary + ? 0 + : Math.max(contentBoundary, currentIndent - tabSize) - currentIndent); + + return { + lineText: shiftLineIndent(lineText, appliedIndentDelta), + appliedIndentDelta, + }; +} + +export interface OutlinerFenceVerticalMoveTarget { + lineText: string; + cursorCharacter: number; +} + +export function getOutlinerFenceVerticalMoveTarget( + lineText: string, + preferredCharacter: number, + contentBoundary: number, +): OutlinerFenceVerticalMoveTarget { + const paddedLineText = lineText.length < contentBoundary + ? `${lineText}${' '.repeat(contentBoundary - lineText.length)}` + : lineText; + + return { + lineText: paddedLineText, + cursorCharacter: Math.max(contentBoundary, Math.min(preferredCharacter, paddedLineText.length)), + }; +} + +/** + * Transforms clipboard text for pasting inside a bullet-owned fenced code + * block, rebasing indentation so the minimum non-blank indent lands on the + * content boundary while preserving relative indentation. + * + * - CRLF is normalised to LF. + * - Blank lines remain blank. + * - The minimum indent of non-blank lines is shifted to `contentBoundary`. + */ +export function formatOutlinerFencePaste( + contentBoundary: number, + clipboardText: string, +): string { + const normalised = clipboardText.replace(/\r\n/g, '\n'); + const lines = normalised.split('\n'); + + // Find minimum indent across non-blank lines + let minIndent = Infinity; + for (const line of lines) { + if (line.trim().length === 0) { continue; } + const indent = getLineIndent(line); + if (indent < minIndent) { minIndent = indent; } + } + + // All blank — no rebasing needed + if (minIndent === Infinity) { minIndent = 0; } + + const delta = contentBoundary - minIndent; + + const result = lines.map(line => { + if (line.trim().length === 0) { return ''; } + return shiftLineIndent(line, delta); + }); + + return result.join('\n'); +} + // ── Paste formatting ─────────────────────────────────────────────────────── /** Result of formatting a multi-line paste for outliner mode. */ diff --git a/vs-code-extension/src/WikilinkCompletionProvider.ts b/vs-code-extension/src/WikilinkCompletionProvider.ts index bca557b..f7cb180 100644 --- a/vs-code-extension/src/WikilinkCompletionProvider.ts +++ b/vs-code-extension/src/WikilinkCompletionProvider.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { IndexService } from './IndexService.js'; -import { findInnermostOpenBracket, findMatchingCloseBracket, isLineInsideFrontMatter } from './CompletionUtils.js'; +import { findInnermostOpenBracket, findMatchingCloseBracket, isLineInsideFrontMatter, isPositionInsideCode } from './CompletionUtils.js'; import { LogService } from './LogService.js'; /** @@ -62,6 +62,10 @@ export class WikilinkCompletionProvider implements vscode.CompletionItemProvider end(); return undefined; } + if (isPositionInsideCode(lines, position.line, Math.max(0, position.character - 1))) { + end(); + return undefined; + } // Find the innermost [[ before the cursor on this line const lineText = document.lineAt(position.line).text; diff --git a/vs-code-extension/src/WikilinkDecorationManager.ts b/vs-code-extension/src/WikilinkDecorationManager.ts index becc64e..77088ee 100644 --- a/vs-code-extension/src/WikilinkDecorationManager.ts +++ b/vs-code-extension/src/WikilinkDecorationManager.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { WikilinkService, type LinkSegment } from 'as-notes-common'; import { LogService } from './LogService.js'; import { isInsideNotesRoot } from './NotesRootService.js'; +import { isPositionInsideCode } from './CompletionUtils.js'; /** * Per-line parse cache entry. @@ -187,6 +188,7 @@ export class WikilinkDecorationManager implements vscode.Disposable { this.cacheUri = editor.document.uri.toString(); this.cacheVersion = editor.document.version; this.lineCache.clear(); + const lines = Array.from({ length: editor.document.lineCount }, (_, index) => editor.document.lineAt(index).text); let totalWikilinks = 0; for (let lineIndex = 0; lineIndex < editor.document.lineCount; lineIndex++) { @@ -194,9 +196,11 @@ export class WikilinkDecorationManager implements vscode.Disposable { const wikilinks = this.wikilinkService.extractWikilinks(lineText); if (wikilinks.length === 0) { continue; } - const segments = this.wikilinkService.computeLinkSegments(wikilinks); + const segments = this.wikilinkService.computeLinkSegments(wikilinks) + .filter(segment => !isPositionInsideCode(lines, lineIndex, segment.startOffset)); + if (segments.length === 0) { continue; } this.lineCache.set(lineIndex, { segments }); - totalWikilinks += wikilinks.length; + totalWikilinks += segments.length; } this.logger.info('decor', `cached ${this.lineCache.size} lines, ${totalWikilinks} wikilinks`); diff --git a/vs-code-extension/src/WikilinkDocumentLinkProvider.ts b/vs-code-extension/src/WikilinkDocumentLinkProvider.ts index ee82199..b551d0f 100644 --- a/vs-code-extension/src/WikilinkDocumentLinkProvider.ts +++ b/vs-code-extension/src/WikilinkDocumentLinkProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { WikilinkService } from 'as-notes-common'; import { WikilinkFileService } from './WikilinkFileService.js'; import type { IndexService } from './IndexService.js'; +import { isPositionInsideCode } from './CompletionUtils.js'; /** * Provides clickable document links for wikilinks in markdown files. @@ -34,6 +35,7 @@ export class WikilinkDocumentLinkProvider implements vscode.DocumentLinkProvider _token: vscode.CancellationToken, ): vscode.DocumentLink[] { const links: vscode.DocumentLink[] = []; + const lines = Array.from({ length: document.lineCount }, (_, index) => document.lineAt(index).text); for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { const line = document.lineAt(lineIndex); @@ -47,6 +49,10 @@ export class WikilinkDocumentLinkProvider implements vscode.DocumentLinkProvider const segments = this.wikilinkService.computeLinkSegments(wikilinks); for (const segment of segments) { + if (isPositionInsideCode(lines, lineIndex, segment.startOffset)) { + continue; + } + const range = new vscode.Range( lineIndex, segment.startOffset, lineIndex, segment.endOffset, diff --git a/vs-code-extension/src/WikilinkHoverProvider.ts b/vs-code-extension/src/WikilinkHoverProvider.ts index b5ceb72..254bc17 100644 --- a/vs-code-extension/src/WikilinkHoverProvider.ts +++ b/vs-code-extension/src/WikilinkHoverProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { WikilinkService } from 'as-notes-common'; import { WikilinkFileService } from './WikilinkFileService.js'; import type { IndexService } from './IndexService.js'; +import { isPositionInsideCode } from './CompletionUtils.js'; /** * Shows a hover tooltip over wikilinks with the target filename, @@ -28,6 +29,11 @@ export class WikilinkHoverProvider implements vscode.HoverProvider { position: vscode.Position, _token: vscode.CancellationToken, ): Promise { + const lines = Array.from({ length: document.lineCount }, (_, index) => document.lineAt(index).text); + if (isPositionInsideCode(lines, position.line, position.character)) { + return undefined; + } + const line = document.lineAt(position.line); const wikilinks = this.wikilinkService.extractWikilinks(line.text); diff --git a/vs-code-extension/src/WikilinkRenameTracker.ts b/vs-code-extension/src/WikilinkRenameTracker.ts index ffe00d2..7d832ee 100644 --- a/vs-code-extension/src/WikilinkRenameTracker.ts +++ b/vs-code-extension/src/WikilinkRenameTracker.ts @@ -14,6 +14,7 @@ import { orderFileRenameOperations, remapUrisForFileOperations, } from './WikilinkFilenameRefactorService.js'; +import { isPositionInsideCode } from './CompletionUtils.js'; /** * Detected rename: a wikilink at the same position now has a different pageName. @@ -189,6 +190,11 @@ export class WikilinkRenameTracker implements vscode.Disposable { const editor = vscode.window.activeTextEditor; if (editor && editor.document.uri.toString() === docKey) { const cursorPos = editor.selection.active; + const lines = Array.from({ length: event.document.lineCount }, (_, index) => event.document.lineAt(index).text); + if (isPositionInsideCode(lines, cursorPos.line, Math.max(0, cursorPos.character - 1))) { + this.pendingEdit = undefined; + return; + } const lineText = event.document.lineAt(cursorPos.line).text; const wikilinks = this.wikilinkService.extractWikilinks(lineText); const outermost = this.findOutermostWikilinkAtOffset(wikilinks, cursorPos.character); @@ -237,6 +243,11 @@ export class WikilinkRenameTracker implements vscode.Disposable { const cursorPos = event.selections[0].active; const document = event.textEditor.document; + const lines = Array.from({ length: document.lineCount }, (_, index) => document.lineAt(index).text); + if (isPositionInsideCode(lines, cursorPos.line, Math.max(0, cursorPos.character - 1))) { + this.pendingEdit = undefined; + return; + } const lineText = document.lineAt(cursorPos.line).text; const wikilinks = this.wikilinkService.extractWikilinks(lineText); const outermost = this.findOutermostWikilinkAtOffset(wikilinks, cursorPos.character); @@ -313,11 +324,15 @@ export class WikilinkRenameTracker implements vscode.Disposable { line: number; } const currentWikilinks: CurrentWikilink[] = []; + const lines = Array.from({ length: document.lineCount }, (_, index) => document.lineAt(index).text); for (let line = 0; line < document.lineCount; line++) { const text = document.lineAt(line).text; const wikilinks = this.wikilinkService.extractWikilinks(text); for (const wl of wikilinks) { + if (isPositionInsideCode(lines, line, wl.startPositionInText)) { + continue; + } currentWikilinks.push({ pageName: wl.pageName, startPosition: wl.startPositionInText, diff --git a/vs-code-extension/src/extension.ts b/vs-code-extension/src/extension.ts index 18cd2e0..258c895 100644 --- a/vs-code-extension/src/extension.ts +++ b/vs-code-extension/src/extension.ts @@ -46,7 +46,7 @@ import { openDatePicker } from './DatePickerService.js'; import { insertTaskDueDate, insertTaskCompletionDate, insertTagAtTaskStart } from './TaskHashtagService.js'; import { toggleFrontMatterField, cycleFrontMatterField, publishToHtml, configurePublish } from './PublishService.js'; import { generateTable, addColumns, addRows, formatTable, removeCurrentRow, removeCurrentColumn, removeRowsAbove, removeRowsBelow, removeColumnsRight, removeColumnsLeft } from './TableService.js'; -import { isOnBulletLine, getOutlinerEnterInsert, toggleOutlinerTodoLine, isCodeFenceOpen, getCodeFenceEnterInsert, formatOutlinerPaste, isStandaloneCodeFenceOpen, getStandaloneCodeFenceEnterInsert, isClosingCodeFenceLine, getClosingFenceBulletInsert, isCodeFenceUnbalanced, getMaxOutlinerIndent } from './OutlinerService.js'; +import { isOnBulletLine, getOutlinerEnterInsert, getFenceTokenCursorZone, getOutlinerFirstChildLine, isOutlinerBackspaceMergeCandidate, getOutlinerBackspaceTargetLine, getOutlinerFenceContentBoundary, canJoinOutlinerFenceContentWithPreviousLine, getOutlinerFenceVerticalMoveTarget, getOwningOutlinerBranchLine, isOutlinerFenceBackspaceBlocked, shiftOutlinerFenceContentLine, toggleOutlinerTodoLine, isCodeFenceOpen, getCodeFenceEnterInsert, getBulletCodeFenceEnterInsert, formatOutlinerPaste, formatOutlinerFencePaste, isStandaloneCodeFenceOpen, getStandaloneCodeFenceEnterInsert, isClosingCodeFenceLine, getClosingFenceBulletInsert, isCodeFenceUnbalanced, canIndentOutlinerBranch, getOutlinerBranchRange, getOutlinerBranchActionLine, moveOutlinerBranch } from './OutlinerService.js'; import { KanbanStore } from './KanbanStore.js'; import { KanbanBoardConfigStore } from './KanbanBoardConfigStore.js'; import { KanbanEditorPanel } from './KanbanEditorPanel.js'; @@ -301,15 +301,40 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte }), ); + const getDocumentLines = (document: vscode.TextDocument): string[] => Array.from( + { length: document.lineCount }, + (_, index) => document.lineAt(index).text, + ); + let isNormalizingOutlinerFenceSelection = false; + // Track whether the active cursor is on a bullet line or a code fence line const syncOutlinerLineContext = (editor: vscode.TextEditor | undefined) => { if (!editor || editor.document.languageId !== 'markdown') { vscode.commands.executeCommand('setContext', 'as-notes.onBulletLine', false); + vscode.commands.executeCommand('setContext', 'as-notes.onOutlinerBranchLine', false); + vscode.commands.executeCommand('setContext', 'as-notes.onOutlinerBackspaceMergePoint', false); + vscode.commands.executeCommand('setContext', 'as-notes.insideOutlinerFenceContent', false); vscode.commands.executeCommand('setContext', 'as-notes.onCodeFenceLine', false); return; } + const lines = getDocumentLines(editor.document); const onBullet = editor.selections.some( - sel => isOnBulletLine(editor.document.lineAt(sel.active.line).text), + sel => getOutlinerFenceContentBoundary(lines, sel.active.line) === null + && isOnBulletLine(editor.document.lineAt(sel.active.line).text), + ); + const onOutlinerBranchLine = editor.selections.some( + sel => getOutlinerFenceContentBoundary(lines, sel.active.line) === null + && getOutlinerBranchActionLine(lines, sel.active.line) !== null, + ); + const onOutlinerBackspaceMergePoint = editor.selections.length === 1 + && editor.selection.isEmpty + && getOutlinerFenceContentBoundary(lines, editor.selection.active.line) === null + && isOutlinerBackspaceMergeCandidate( + editor.document.lineAt(editor.selection.active.line).text, + editor.selection.active.character, + ); + const insideOutlinerFenceContent = editor.selections.some( + sel => getOutlinerFenceContentBoundary(lines, sel.active.line) !== null, ); const onCodeFence = editor.selections.some( sel => { @@ -318,15 +343,116 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte }, ); vscode.commands.executeCommand('setContext', 'as-notes.onBulletLine', onBullet); + vscode.commands.executeCommand('setContext', 'as-notes.onOutlinerBranchLine', onOutlinerBranchLine); + vscode.commands.executeCommand('setContext', 'as-notes.onOutlinerBackspaceMergePoint', onOutlinerBackspaceMergePoint); + vscode.commands.executeCommand('setContext', 'as-notes.insideOutlinerFenceContent', insideOutlinerFenceContent); vscode.commands.executeCommand('setContext', 'as-notes.onCodeFenceLine', onCodeFence); }; + const normalizeOutlinerFenceSelections = (editor: vscode.TextEditor | undefined) => { + if (isNormalizingOutlinerFenceSelection || !editor || editor.document.languageId !== 'markdown') { + return; + } + + if (editor.selections.some(sel => !sel.isEmpty)) { + return; + } + + const lines = getDocumentLines(editor.document); + const normalizedTargets = editor.selections.map(sel => { + const lineIndex = sel.active.line; + const contentBoundary = getOutlinerFenceContentBoundary(lines, lineIndex); + if (contentBoundary === null || sel.active.character >= contentBoundary) { + return null; + } + + const lineText = editor.document.lineAt(lineIndex).text; + if (lineText.trim().length !== 0) { + return null; + } + + const target = getOutlinerFenceVerticalMoveTarget(lineText, sel.active.character, contentBoundary); + if (target.lineText === lineText && target.cursorCharacter === sel.active.character) { + return null; + } + + return { + lineIndex, + lineText: target.lineText, + cursorCharacter: target.cursorCharacter, + }; + }); + + if (normalizedTargets.every(target => target === null)) { + return; + } + + const applySelections = () => { + isNormalizingOutlinerFenceSelection = true; + try { + editor.selections = editor.selections.map((selection, index) => { + const target = normalizedTargets[index]; + if (!target) { + return selection; + } + + const pos = new vscode.Position(target.lineIndex, target.cursorCharacter); + return new vscode.Selection(pos, pos); + }); + } finally { + isNormalizingOutlinerFenceSelection = false; + } + + syncOutlinerLineContext(editor); + }; + + const replacementByLine = new Map(); + for (const target of normalizedTargets) { + if (!target) { + continue; + } + + if (target.lineText !== editor.document.lineAt(target.lineIndex).text) { + replacementByLine.set(target.lineIndex, target.lineText); + } + } + + if (replacementByLine.size === 0) { + applySelections(); + return; + } + + void editor.edit(editBuilder => { + for (const [lineIndex, lineText] of replacementByLine) { + editBuilder.replace(editor.document.lineAt(lineIndex).range, lineText); + } + }).then(success => { + if (!success) { + return; + } + + applySelections(); + }); + }; + context.subscriptions.push( - vscode.window.onDidChangeTextEditorSelection((e) => syncOutlinerLineContext(e.textEditor)), + vscode.window.onDidChangeTextEditorSelection((e) => { + syncOutlinerLineContext(e.textEditor); + normalizeOutlinerFenceSelections(e.textEditor); + }), ); context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => syncOutlinerLineContext(editor)), ); + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((e) => { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor || e.document !== activeEditor.document) { + return; + } + syncOutlinerLineContext(activeEditor); + }), + ); // Initialise for currently active editor syncOutlinerLineContext(vscode.window.activeTextEditor); @@ -335,9 +461,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte const editor = vscode.window.activeTextEditor; if (!editor) { return; } - // Track which selections triggered code fence skeleton insertion so we - // can reposition cursors to the blank content line after the edit. - let hasCodeFenceSkeleton = false; + const allLines = editor.document.getText().split('\n'); + const nextSelections: vscode.Selection[] = []; editor.edit(editBuilder => { for (const selection of editor.selections) { @@ -346,32 +471,45 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte selection.active.line, editor.document.lineAt(selection.active.line).range.end.character, ); + const cursorCharacter = selection.active.character; + const bulletFenceInsert = isCodeFenceOpen(lineText) + ? getBulletCodeFenceEnterInsert(allLines, selection.active.line, cursorCharacter) + : null; // 1. Bullet line ending with opening code fence → open code block - if (isCodeFenceOpen(lineText)) { - hasCodeFenceSkeleton = true; - editBuilder.insert(lineEnd, getCodeFenceEnterInsert(lineText)); + if (bulletFenceInsert !== null) { + const insertText = bulletFenceInsert; + const bulletIndent = lineText.match(/^(\s*)- /)?.[1]?.length ?? 0; + editBuilder.insert(lineEnd, insertText); + const pos = new vscode.Position(selection.active.line + 1, bulletIndent + 2); + nextSelections.push(new vscode.Selection(pos, pos)); continue; } - // 2. Bullet line → new bullet at same indentation + // 2. End-of-line Enter on a parent with children → insert a new first child + if (cursorCharacter === lineEnd.character) { + const firstChildLine = getOutlinerFirstChildLine(allLines, selection.active.line); + if (firstChildLine !== null) { + const childIndent = editor.document.lineAt(firstChildLine).text.match(/^(\s*)/)?.[1] ?? ''; + const childInsert = `${getOutlinerEnterInsert(lineText, childIndent).slice(1)}\n`; + const insertAt = new vscode.Position(firstChildLine, 0); + editBuilder.insert(insertAt, childInsert); + const pos = new vscode.Position(firstChildLine, childInsert.length - 1); + nextSelections.push(new vscode.Selection(pos, pos)); + continue; + } + } + + // 3. Bullet line → new bullet at same indentation const insertText = getOutlinerEnterInsert(lineText); editBuilder.delete(new vscode.Range(selection.active, lineEnd)); editBuilder.insert(selection.active, insertText); + const pos = new vscode.Position(selection.active.line + 1, insertText.length - 1); + nextSelections.push(new vscode.Selection(pos, pos)); } }).then(success => { - if (!success || !hasCodeFenceSkeleton) { return; } - // After code fence skeleton insertion, VS Code places the cursor at - // the end of the closing ```. Reposition it to the blank line inside - // the fence (one line above the closing ```). - const newSelections: vscode.Selection[] = editor.selections.map(sel => { - const closingLine = sel.active.line; - const contentLine = closingLine > 0 ? closingLine - 1 : closingLine; - const contentLineLength = editor.document.lineAt(contentLine).text.length; - const pos = new vscode.Position(contentLine, contentLineLength); - return new vscode.Selection(pos, pos); - }); - editor.selections = newSelections; + if (!success || nextSelections.length !== editor.selections.length) { return; } + editor.selections = nextSelections; }); }), ); @@ -386,7 +524,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte const allLines = editor.document.getText().split('\n'); const outlinerMode = vscode.workspace.getConfiguration('as-notes').get('outlinerMode', false); - let hasCodeFenceSkeleton = false; + const nextSelections: vscode.Selection[] = []; editor.edit(editBuilder => { for (const selection of editor.selections) { @@ -395,6 +533,17 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte selection.active.line, editor.document.lineAt(selection.active.line).range.end.character, ); + const cursorCharacter = selection.active.character; + const fenceZone = getFenceTokenCursorZone(lineText, cursorCharacter); + + if (fenceZone === 'before') { + const trailingText = lineText.slice(cursorCharacter); + editBuilder.delete(new vscode.Range(selection.active, lineEnd)); + editBuilder.insert(selection.active, `\n${trailingText}`); + const pos = new vscode.Position(selection.active.line + 1, 0); + nextSelections.push(new vscode.Selection(pos, pos)); + continue; + } // Closing fence of a bullet code block (checked first to avoid // misidentifying it as an unbalanced standalone opener) @@ -403,8 +552,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte if (bulletResult !== null) { if (outlinerMode) { editBuilder.insert(lineEnd, bulletResult); + const pos = new vscode.Position(selection.active.line + 1, bulletResult.length - 1); + nextSelections.push(new vscode.Selection(pos, pos)); } else { editBuilder.insert(lineEnd, '\n'); + const pos = new vscode.Position(selection.active.line + 1, 0); + nextSelections.push(new vscode.Selection(pos, pos)); } continue; } @@ -412,25 +565,29 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte // Standalone fence (opening with language, or bare ```) if (isStandaloneCodeFenceOpen(lineText)) { + const indent = lineText.match(/^(\s*)/)?.[1]?.length ?? 0; + const branchRoot = getOwningOutlinerBranchLine(allLines, selection.active.line); + const branchBoundary = branchRoot === null + ? indent + : (editor.document.lineAt(branchRoot).text.match(/^(\s*)/)?.[1]?.length ?? 0) + 2; + const cursorIndent = Math.max(indent, branchBoundary); if (isCodeFenceUnbalanced(allLines, selection.active.line)) { - hasCodeFenceSkeleton = true; editBuilder.insert(lineEnd, getStandaloneCodeFenceEnterInsert(lineText)); + const pos = new vscode.Position(selection.active.line + 1, cursorIndent); + nextSelections.push(new vscode.Selection(pos, pos)); } else { editBuilder.insert(lineEnd, '\n'); + const pos = new vscode.Position(selection.active.line + 1, cursorIndent); + nextSelections.push(new vscode.Selection(pos, pos)); } continue; } + + nextSelections.push(selection); } }).then(success => { - if (!success || !hasCodeFenceSkeleton) { return; } - const newSelections: vscode.Selection[] = editor.selections.map(sel => { - const closingLine = sel.active.line; - const contentLine = closingLine > 0 ? closingLine - 1 : closingLine; - const contentLineLength = editor.document.lineAt(contentLine).text.length; - const pos = new vscode.Position(contentLine, contentLineLength); - return new vscode.Selection(pos, pos); - }); - editor.selections = newSelections; + if (!success || nextSelections.length !== editor.selections.length) { return; } + editor.selections = nextSelections; }); }), ); @@ -440,27 +597,24 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte const editor = vscode.window.activeTextEditor; if (!editor) { return; } - const allLines = editor.document.getText().split('\n'); - const tabSize = editor.options.tabSize as number ?? 4; - - // Only indent if every selection's bullet line would stay within - // one tab stop of the nearest bullet above it. - const allAllowed = editor.selections.every(sel => { - const lineText = editor.document.lineAt(sel.active.line).text; - const currentIndent = (lineText.match(/^(\s*)/)?.[1]?.length) ?? 0; - const maxIndent = getMaxOutlinerIndent(allLines, sel.active.line, tabSize); - return currentIndent + tabSize <= maxIndent; - }); - - if (allAllowed) { - vscode.commands.executeCommand('editor.action.indentLines'); + if (applyOutlinerFenceContentIndent(editor, 'indent')) { + return; } + + applyOutlinerBranchMove(editor, 'indent'); }), ); context.subscriptions.push( vscode.commands.registerCommand('as-notes.outlinerOutdent', () => { - vscode.commands.executeCommand('editor.action.outdentLines'); + const editor = vscode.window.activeTextEditor; + if (!editor) { return; } + + if (applyOutlinerFenceContentIndent(editor, 'outdent')) { + return; + } + + applyOutlinerBranchMove(editor, 'outdent'); }), ); @@ -471,25 +625,121 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte const clipboardText = await vscode.env.clipboard.readText(); if (!clipboardText) { return; } - // Check if any selection is on a bullet line with multi-line clipboard const sel = editor.selection; const lineText = editor.document.lineAt(sel.active.line).text; + const lines = getDocumentLines(editor.document); + const fenceContentBoundary = getOutlinerFenceContentBoundary(lines, sel.active.line); + + if (fenceContentBoundary !== null) { + const shouldFormatFencePaste = !sel.isEmpty + || lineText.trim().length === 0 + || clipboardText.includes('\n') + || clipboardText.includes('\r'); + + if (shouldFormatFencePaste) { + const formattedText = formatOutlinerFencePaste(fenceContentBoundary, clipboardText); + const replaceRange = sel.isEmpty && lineText.trim().length === 0 + ? editor.document.lineAt(sel.active.line).range + : sel; + void editor.edit(editBuilder => { + editBuilder.replace(replaceRange, formattedText); + }); + return; + } + + void vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + return; + } + const result = formatOutlinerPaste(lineText, sel.active.character, clipboardText); if (!result) { // Single-line or empty paste: fall through to default paste - vscode.commands.executeCommand('editor.action.clipboardPasteAction'); + void vscode.commands.executeCommand('editor.action.clipboardPasteAction'); return; } // Replace the entire line with the formatted multi-line bullets const line = editor.document.lineAt(sel.active.line); - editor.edit(editBuilder => { + void editor.edit(editBuilder => { editBuilder.replace(line.range, result.text); }); }), ); + context.subscriptions.push( + vscode.commands.registerCommand('as-notes.outlinerFenceArrowDown', () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { return; } + + applyOutlinerFenceVerticalMove(editor, 'down'); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('as-notes.outlinerFenceArrowUp', () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { return; } + + applyOutlinerFenceVerticalMove(editor, 'up'); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('as-notes.outlinerBackspace', () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selections.length !== 1 || !editor.selection.isEmpty) { + void vscode.commands.executeCommand('deleteLeft'); + return; + } + + const lineIndex = editor.selection.active.line; + const lineText = editor.document.lineAt(lineIndex).text; + const lines = getDocumentLines(editor.document); + const fenceContentBoundary = getOutlinerFenceContentBoundary(lines, lineIndex); + + if (fenceContentBoundary !== null) { + if (editor.selection.active.character === 0) { + if (canJoinOutlinerFenceContentWithPreviousLine(lines, lineIndex)) { + void vscode.commands.executeCommand('deleteLeft'); + } + return; + } + + if (isOutlinerFenceBackspaceBlocked(lineText, editor.selection.active.character, fenceContentBoundary)) { + return; + } + + void vscode.commands.executeCommand('deleteLeft'); + return; + } + + if (!isOutlinerBackspaceMergeCandidate(lineText, editor.selection.active.character)) { + void vscode.commands.executeCommand('deleteLeft'); + return; + } + + const targetLine = getOutlinerBackspaceTargetLine(lines, lineIndex); + if (targetLine === null) { + void vscode.commands.executeCommand('deleteLeft'); + return; + } + + const deleteRange = editor.document.lineAt(lineIndex).rangeIncludingLineBreak; + void editor.edit(editBuilder => { + editBuilder.delete(deleteRange); + }).then(success => { + if (!success) { + return; + } + + const targetLineEnd = editor.document.lineAt(targetLine).range.end.character; + const pos = new vscode.Position(targetLine, targetLineEnd); + editor.selections = [new vscode.Selection(pos, pos)]; + }); + }), + ); + // Register commands — always available (init can be called from passive mode) context.subscriptions.push( vscode.commands.registerCommand('as-notes.initWorkspace', () => initWorkspace(context)), @@ -2970,6 +3220,236 @@ function getWorkspaceRoot(): vscode.Uri | undefined { return vscode.workspace.workspaceFolders?.[0]?.uri; } +function applyOutlinerBranchMove( + editor: vscode.TextEditor, + direction: 'indent' | 'outdent', +): void { + const tabSize = editor.options.tabSize as number ?? 4; + const originalLines = editor.document.getText().split('\n'); + const originalSelections = editor.selections.map(sel => sel); + const candidateLines = Array.from(new Set(originalSelections.map(sel => sel.active.line))).sort((a, b) => a - b); + + const candidateRoots = candidateLines.map(lineIndex => getOutlinerBranchActionLine(originalLines, lineIndex)); + if (candidateRoots.some(lineIndex => lineIndex === null)) { + void vscode.commands.executeCommand( + direction === 'indent' ? 'editor.action.indentLines' : 'editor.action.outdentLines', + ); + return; + } + + const resolvedRootCandidates = Array.from(new Set(candidateRoots as number[])).sort((a, b) => a - b); + + const rootLines: number[] = []; + const coveredRanges: Array<{ startLine: number; endLine: number }> = []; + + for (const lineIndex of resolvedRootCandidates) { + if (coveredRanges.some(range => lineIndex >= range.startLine && lineIndex <= range.endLine)) { + continue; + } + const range = getOutlinerBranchRange(originalLines, lineIndex); + if (!range) { + continue; + } + rootLines.push(lineIndex); + coveredRanges.push(range); + } + + if (rootLines.length === 0) { + return; + } + + if (direction === 'indent' && !rootLines.every(lineIndex => canIndentOutlinerBranch(originalLines, lineIndex, tabSize))) { + return; + } + + let updatedLines = originalLines.slice(); + const appliedRanges: Array<{ startLine: number; endLine: number; delta: number }> = []; + + for (const lineIndex of rootLines) { + const result = moveOutlinerBranch(updatedLines, lineIndex, tabSize, direction); + if (!result) { + continue; + } + updatedLines = result.lines; + appliedRanges.push({ + startLine: result.startLine, + endLine: result.endLine, + delta: result.appliedIndentDelta, + }); + } + + const updatedText = updatedLines.join('\n'); + const originalText = editor.document.getText(); + if (updatedText === originalText) { + return; + } + + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(originalText.length), + ); + + void editor.edit(editBuilder => { + editBuilder.replace(fullRange, updatedText); + }).then(success => { + if (!success) { + return; + } + editor.selections = originalSelections.map(selection => { + const adjustCharacter = (line: number, character: number): number => { + const delta = appliedRanges.reduce((sum, range) => { + if (line >= range.startLine && line <= range.endLine) { + return sum + range.delta; + } + return sum; + }, 0); + return Math.max(0, character + delta); + }; + + const anchor = new vscode.Position( + selection.anchor.line, + adjustCharacter(selection.anchor.line, selection.anchor.character), + ); + const active = new vscode.Position( + selection.active.line, + adjustCharacter(selection.active.line, selection.active.character), + ); + return new vscode.Selection(anchor, active); + }); + }); +} + +function applyOutlinerFenceContentIndent( + editor: vscode.TextEditor, + direction: 'indent' | 'outdent', +): boolean { + const tabSize = editor.options.tabSize as number ?? 4; + const originalLines = Array.from( + { length: editor.document.lineCount }, + (_, index) => editor.document.lineAt(index).text, + ); + const originalSelections = editor.selections.map(sel => sel); + const candidateLines = Array.from(new Set(originalSelections.map(sel => sel.active.line))).sort((a, b) => a - b); + + const shiftResults = candidateLines.map(lineIndex => { + const contentBoundary = getOutlinerFenceContentBoundary(originalLines, lineIndex); + if (contentBoundary === null) { + return null; + } + + return { + lineIndex, + ...shiftOutlinerFenceContentLine(originalLines[lineIndex], tabSize, direction, contentBoundary), + }; + }); + + if (shiftResults.some(result => result === null)) { + return false; + } + + const resolvedShifts = shiftResults as Array<{ + lineIndex: number; + lineText: string; + appliedIndentDelta: number; + }>; + + if (resolvedShifts.every(result => result.appliedIndentDelta === 0)) { + return true; + } + + void editor.edit(editBuilder => { + for (const result of resolvedShifts) { + if (result.appliedIndentDelta === 0) { + continue; + } + + editBuilder.replace(editor.document.lineAt(result.lineIndex).range, result.lineText); + } + }).then(success => { + if (!success) { + return; + } + + const deltaByLine = new Map(resolvedShifts.map(result => [result.lineIndex, result.appliedIndentDelta])); + editor.selections = originalSelections.map(selection => { + const adjustCharacter = (line: number, character: number): number => { + const delta = deltaByLine.get(line) ?? 0; + return Math.max(0, character + delta); + }; + + const anchor = new vscode.Position( + selection.anchor.line, + adjustCharacter(selection.anchor.line, selection.anchor.character), + ); + const active = new vscode.Position( + selection.active.line, + adjustCharacter(selection.active.line, selection.active.character), + ); + return new vscode.Selection(anchor, active); + }); + }); + + return true; +} + +function applyOutlinerFenceVerticalMove( + editor: vscode.TextEditor, + direction: 'up' | 'down', +): void { + if (editor.selections.length !== 1 || !editor.selection.isEmpty) { + void vscode.commands.executeCommand(direction === 'down' ? 'cursorDown' : 'cursorUp'); + return; + } + + const originalLines = Array.from( + { length: editor.document.lineCount }, + (_, index) => editor.document.lineAt(index).text, + ); + const currentLine = editor.selection.active.line; + const currentBoundary = getOutlinerFenceContentBoundary(originalLines, currentLine); + if (currentBoundary === null) { + void vscode.commands.executeCommand(direction === 'down' ? 'cursorDown' : 'cursorUp'); + return; + } + + const targetLine = currentLine + (direction === 'down' ? 1 : -1); + if (targetLine < 0 || targetLine >= editor.document.lineCount) { + void vscode.commands.executeCommand(direction === 'down' ? 'cursorDown' : 'cursorUp'); + return; + } + + const targetBoundary = getOutlinerFenceContentBoundary(originalLines, targetLine); + if (targetBoundary === null) { + void vscode.commands.executeCommand(direction === 'down' ? 'cursorDown' : 'cursorUp'); + return; + } + + const target = getOutlinerFenceVerticalMoveTarget( + editor.document.lineAt(targetLine).text, + editor.selection.active.character, + targetBoundary, + ); + + const moveCursor = () => { + const pos = new vscode.Position(targetLine, target.cursorCharacter); + editor.selections = [new vscode.Selection(pos, pos)]; + }; + + if (target.lineText === editor.document.lineAt(targetLine).text) { + moveCursor(); + return; + } + + void editor.edit(editBuilder => { + editBuilder.replace(editor.document.lineAt(targetLine).range, target.lineText); + }).then(success => { + if (!success) { + return; + } + moveCursor(); + }); +} + function isMarkdown(doc: vscode.TextDocument): boolean { return doc.languageId === 'markdown' && !isEncryptedFileUri(doc.uri); } diff --git a/vs-code-extension/src/test/OutlinerService.test.ts b/vs-code-extension/src/test/OutlinerService.test.ts index 6df524e..39506bf 100644 --- a/vs-code-extension/src/test/OutlinerService.test.ts +++ b/vs-code-extension/src/test/OutlinerService.test.ts @@ -2,16 +2,34 @@ import { describe, it, expect } from 'vitest'; import { isOnBulletLine, getOutlinerEnterInsert, + getFenceTokenCursorZone, + isOutlinerBackspaceMergeCandidate, + getOutlinerBackspaceTargetLine, toggleOutlinerTodoLine, isCodeFenceOpen, getCodeFenceEnterInsert, + getBulletCodeFenceEnterInsert, formatOutlinerPaste, isStandaloneCodeFenceOpen, getStandaloneCodeFenceEnterInsert, isClosingCodeFenceLine, + getBulletCodeFenceContext, getClosingFenceBulletInsert, + isInsideBulletCodeFence, isCodeFenceUnbalanced, getMaxOutlinerIndent, + getOutlinerBranchRange, + getOutlinerFirstChildLine, + getOwningOutlinerBranchLine, + getOutlinerBranchActionLine, + canIndentOutlinerBranch, + moveOutlinerBranch, + getOutlinerFenceContentBoundary, + canJoinOutlinerFenceContentWithPreviousLine, + getOutlinerFenceVerticalMoveTarget, + isOutlinerFenceBackspaceBlocked, + shiftOutlinerFenceContentLine, + formatOutlinerFencePaste, } from '../OutlinerService.js'; // ── isOnBulletLine ───────────────────────────────────────────────────────── @@ -120,6 +138,113 @@ describe('getOutlinerEnterInsert', () => { it('returns new indented unchecked todo for an indented done todo', () => { expect(getOutlinerEnterInsert(' - [x] nested done')).toBe('\n - [ ] '); }); + + it('can override the target indentation for child-first insertion', () => { + expect(getOutlinerEnterInsert('- parent', ' ')).toBe('\n - '); + expect(getOutlinerEnterInsert('- [x] parent', ' ')).toBe('\n - [ ] '); + }); +}); + +// ── fence token cursor position ─────────────────────────────────────────── + +describe('getFenceTokenCursorZone', () => { + it('returns before when the cursor is before the first backtick', () => { + expect(getFenceTokenCursorZone('- ```ts', 2)).toBe('before'); + }); + + it('returns inside when the cursor is inside the backtick token', () => { + expect(getFenceTokenCursorZone('- ```ts', 4)).toBe('inside'); + }); + + it('returns after when the cursor is after the backtick token', () => { + expect(getFenceTokenCursorZone('- ```ts', 5)).toBe('after'); + }); + + it('returns none when the line does not contain a fence token', () => { + expect(getFenceTokenCursorZone('- plain bullet', 3)).toBe('none'); + }); +}); + +// ── outliner backspace merge ───────────────────────────────────────────── + +describe('isOutlinerBackspaceMergeCandidate', () => { + it('returns true for an empty plain bullet at the bullet content start', () => { + expect(isOutlinerBackspaceMergeCandidate('- ', 2)).toBe(true); + expect(isOutlinerBackspaceMergeCandidate('-', 1)).toBe(true); + expect(isOutlinerBackspaceMergeCandidate(' - ', 6)).toBe(true); + expect(isOutlinerBackspaceMergeCandidate(' -', 5)).toBe(true); + }); + + it('returns true for an empty todo bullet at the checkbox content start', () => { + expect(isOutlinerBackspaceMergeCandidate('- [ ] ', 6)).toBe(true); + }); + + it('returns false for non-empty bullets or other cursor positions', () => { + expect(isOutlinerBackspaceMergeCandidate('- item', 2)).toBe(false); + expect(isOutlinerBackspaceMergeCandidate('- ', 1)).toBe(false); + expect(isOutlinerBackspaceMergeCandidate('- [ ] ', 5)).toBe(false); + }); +}); + +describe('getOutlinerBackspaceTargetLine', () => { + it('merges an empty root-level sibling into the previous sibling bullet', () => { + const lines = [ + '- first', + '-', + ]; + + expect(getOutlinerBackspaceTargetLine(lines, 1)).toBe(0); + }); + + it('merges a first child into its parent when there is no previous sibling', () => { + const lines = [ + '- parent', + ' - ', + ]; + + expect(getOutlinerBackspaceTargetLine(lines, 1)).toBe(0); + }); + + it('prefers the previous sibling over the parent for nested empty bullets', () => { + const lines = [ + '- parent', + ' - child one', + ' - grandchild', + ' - ', + ]; + + expect(getOutlinerBackspaceTargetLine(lines, 3)).toBe(1); + }); + + it('returns null when there is no previous structural predecessor', () => { + const lines = ['- ']; + + expect(getOutlinerBackspaceTargetLine(lines, 0)).toBeNull(); + }); + + it('returns null for non-empty bullets', () => { + const lines = ['- first', '- second']; + + expect(getOutlinerBackspaceTargetLine(lines, 1)).toBeNull(); + }); + + it('handles \\r\\n line endings from getText().split(\\n)', () => { + const lines = [ + '- first\r', + '- \r', + ]; + + expect(getOutlinerBackspaceTargetLine(lines, 1)).toBe(0); + }); + + it('handles bare dash with \\r\\n line endings', () => { + const lines = [ + '- parent\r', + ' -\r', + ]; + + expect(getOutlinerBackspaceTargetLine(lines, 1)).toBe(0); + }); }); // ── toggleOutlinerTodoLine ───────────────────────────────────────────────── @@ -275,6 +400,132 @@ describe('getCodeFenceEnterInsert', () => { }); }); +// ── bullet-owned code fence context ─────────────────────────────────────── + +describe('getBulletCodeFenceContext', () => { + it('returns bullet fence context for the opening bullet line', () => { + const lines = [ + '- ```csharp', + ' Console.WriteLine("hi");', + ' ```', + '- sibling', + ]; + + expect(getBulletCodeFenceContext(lines, 0)).toEqual({ + openerLine: 0, + rootIndent: 0, + contentIndent: 2, + isTodo: false, + }); + }); + + it('returns bullet fence context for content and closing fence lines', () => { + const lines = [ + ' - [ ] ```ts', + ' const value = 1;', + ' ```', + ' - next', + ]; + + expect(getBulletCodeFenceContext(lines, 1)).toEqual({ + openerLine: 0, + rootIndent: 4, + contentIndent: 6, + isTodo: true, + }); + expect(getBulletCodeFenceContext(lines, 2)).toEqual({ + openerLine: 0, + rootIndent: 4, + contentIndent: 6, + isTodo: true, + }); + }); + + it('returns null once the bullet-owned fence has closed', () => { + const lines = [ + '- ```js', + ' const value = 1;', + ' ```', + '- next', + ]; + + expect(getBulletCodeFenceContext(lines, 3)).toBeNull(); + }); + + it('does not treat standalone fences as bullet-owned fence context', () => { + const lines = [ + '```js', + 'const value = 1;', + '```', + ]; + + expect(getBulletCodeFenceContext(lines, 1)).toBeNull(); + }); +}); + +describe('isInsideBulletCodeFence', () => { + it('returns true for opening, content, and closing lines of a bullet-owned fence', () => { + const lines = [ + '- ```python', + ' print("hello")', + ' ```', + '- sibling', + ]; + + expect(isInsideBulletCodeFence(lines, 0)).toBe(true); + expect(isInsideBulletCodeFence(lines, 1)).toBe(true); + expect(isInsideBulletCodeFence(lines, 2)).toBe(true); + expect(isInsideBulletCodeFence(lines, 3)).toBe(false); + }); +}); + +describe('getBulletCodeFenceEnterInsert', () => { + it('returns null when the cursor is before the fence token on a bullet fence line', () => { + const lines = [ + '- ```csharp', + ' Console.WriteLine("hi");', + ' ```', + ]; + + expect(getBulletCodeFenceEnterInsert(lines, 0, 1)).toBeNull(); + }); + + it('treats the caret at the first backtick as a valid opening-fence Enter position', () => { + const lines = [ + '- ```csharp', + ' Console.WriteLine("hi");', + ' ```', + ]; + + expect(getBulletCodeFenceEnterInsert(lines, 0, 2)).toBe('\n '); + }); + + it('returns a full skeleton when a bullet fence has no closing fence yet', () => { + const lines = [ + '- ```csharp', + ' Console.WriteLine("hi");', + ]; + + expect(getBulletCodeFenceEnterInsert(lines, 0, 5)).toBe('\n \n ```'); + }); + + it('returns only a continued content line when the bullet fence is already closed later', () => { + const lines = [ + '- ```csharp', + ' Console.WriteLine("hi");', + ' ```', + '- sibling', + ]; + + expect(getBulletCodeFenceEnterInsert(lines, 0, 5)).toBe('\n '); + }); + + it('returns null for lines that are not bullet fence openers', () => { + const lines = ['- plain bullet']; + expect(getBulletCodeFenceEnterInsert(lines, 0)).toBeNull(); + }); +}); + // ── formatOutlinerPaste ──────────────────────────────────────────────────── describe('formatOutlinerPaste', () => { @@ -672,6 +923,17 @@ describe('isCodeFenceUnbalanced', () => { expect(isCodeFenceUnbalanced(lines, 4)).toBe(true); }); + it('treats a new standalone fence under an outliner branch as unbalanced after a prior bullet-owned fence has closed', () => { + const lines = [ + '- ```js', + ' const a = 1;', + ' ```', + ' ```', + ]; + + expect(isCodeFenceUnbalanced(lines, 3)).toBe(true); + }); + it('returns false for both fences of a pair with indentation', () => { const lines = [ ' ```javascript', @@ -900,3 +1162,639 @@ describe('getMaxOutlinerIndent', () => { expect(getMaxOutlinerIndent(lines, 3, 4)).toBe(12); }); }); + +// ── subtree-aware branch moves ──────────────────────────────────────────── + +describe('getOutlinerBranchRange', () => { + it('includes non-bullet continuation lines indented to content indent', () => { + const lines = [ + '- parent', + ' paragraph', + ' > quote', + ' ![img](../assets/leaf.png)', + '- sibling', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 3, + rootIndent: 0, + }); + }); + + it('stops before a non-bullet line indented less than content indent', () => { + const lines = [ + '- parent', + ' paragraph', + 'plain root paragraph', + '- sibling', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 1, + rootIndent: 0, + }); + }); + + it('treats blank lines as neutral when followed by valid continuation content', () => { + const lines = [ + '- parent', + ' paragraph', + '', + '', + ' continued paragraph', + '- sibling', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 4, + rootIndent: 0, + }); + }); + + it('does not keep trailing blank lines when the next significant line closes the branch', () => { + const lines = [ + '- parent', + ' paragraph', + '', + '', + 'plain root paragraph', + '- sibling', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 1, + rootIndent: 0, + }); + }); + + it('treats a heading outside continuation indent as a hard boundary', () => { + const lines = [ + '- parent', + ' paragraph', + '# Heading', + '- sibling', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 1, + rootIndent: 0, + }); + }); + + it('includes an indented table but excludes a root-level table after the branch', () => { + const lines = [ + '- parent', + ' | Col | Val |', + ' | --- | --- |', + ' | A | B |', + '| Root | Table |', + '| --- | --- |', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 3, + rootIndent: 0, + }); + }); + + it('keeps fenced blocks with internal blank lines inside the branch', () => { + const lines = [ + '- parent', + ' ```ts', + '', + 'root-looking text inside fence', + '', + ' ```', + 'plain root paragraph', + ]; + + expect(getOutlinerBranchRange(lines, 0)).toEqual({ + startLine: 0, + endLine: 5, + rootIndent: 0, + }); + }); +}); + +describe('getOutlinerFirstChildLine', () => { + it('returns the first descendant bullet line for a branch with children', () => { + const lines = [ + '- parent', + ' paragraph owned by parent', + ' - child one', + ' - grandchild', + ' - child two', + ]; + + expect(getOutlinerFirstChildLine(lines, 0)).toBe(2); + }); + + it('returns null when the branch has no descendant bullets', () => { + const lines = [ + '- parent', + ' paragraph owned by parent', + ' ```ts', + ' const value = 1;', + ' ```', + '- sibling', + ]; + + expect(getOutlinerFirstChildLine(lines, 0)).toBeNull(); + }); +}); + +describe('getOwningOutlinerBranchLine', () => { + it('returns the same line when the current line is a bullet', () => { + const lines = ['- root', ' paragraph']; + expect(getOwningOutlinerBranchLine(lines, 0)).toBe(0); + }); + + it('returns the owning bullet line for a continuation paragraph', () => { + const lines = [ + '- root', + ' paragraph', + ' still part of root', + '- sibling', + ]; + + expect(getOwningOutlinerBranchLine(lines, 2)).toBe(0); + }); + + it('returns the owning bullet line for a blank line kept by lookahead', () => { + const lines = [ + '- root', + ' paragraph', + '', + ' continued paragraph', + '- sibling', + ]; + + expect(getOwningOutlinerBranchLine(lines, 2)).toBe(0); + }); + + it('returns the owning bullet line for an indented closing fence', () => { + const lines = [ + '- root', + ' ```ts', + ' const value = 1;', + ' ```', + '- sibling', + ]; + + expect(getOwningOutlinerBranchLine(lines, 3)).toBe(0); + }); + + it('returns null for a root-level line outside any branch', () => { + const lines = [ + '- root', + ' paragraph', + 'root paragraph', + ]; + + expect(getOwningOutlinerBranchLine(lines, 2)).toBeNull(); + }); +}); + +describe('getOutlinerBranchActionLine', () => { + it('returns the branch root when the cursor is on a bullet fence opener line', () => { + const lines = [ + '- root', + ' - ```csharp', + ' Console.WriteLine("hi");', + ' ```', + ]; + + expect(getOutlinerBranchActionLine(lines, 1)).toBe(1); + }); + + it('returns the owning branch root when the cursor is on a closing fence line', () => { + const lines = [ + '- root', + ' ```ts', + ' const value = 1;', + ' ```', + '- sibling', + ]; + + expect(getOutlinerBranchActionLine(lines, 3)).toBe(0); + }); + + it('returns the owning branch root when the cursor is on a continuation paragraph outside fenced code', () => { + const lines = [ + '- root', + ' paragraph', + ' another paragraph', + '- sibling', + ]; + + expect(getOutlinerBranchActionLine(lines, 2)).toBe(0); + }); + + it('returns null for actual code-content lines inside a bullet-owned fence', () => { + const lines = [ + '- root', + ' ```ts', + ' const value = 1;', + ' ```', + '- sibling', + ]; + + expect(getOutlinerBranchActionLine(lines, 2)).toBeNull(); + }); +}); + +describe('canIndentOutlinerBranch', () => { + it('allows indent when the branch root stays within one tab stop of the nearest bullet above', () => { + const lines = ['- root', '- child', ' - grandchild']; + expect(canIndentOutlinerBranch(lines, 1, 4)).toBe(true); + }); + + it('blocks indent when the branch root would exceed the max indent guard', () => { + const lines = ['- root', ' - child', ' - grandchild']; + expect(canIndentOutlinerBranch(lines, 2, 4)).toBe(false); + }); +}); + +describe('moveOutlinerBranch', () => { + it('indenting a parent pushes direct and deep descendants', () => { + const lines = [ + '- root', + '- parent', + ' - child', + ' - grandchild', + '- sibling', + ]; + + const result = moveOutlinerBranch(lines, 1, 4, 'indent'); + + expect(result).not.toBeNull(); + expect(result!.lines).toEqual([ + '- root', + ' - parent', + ' - child', + ' - grandchild', + '- sibling', + ]); + expect(result!.startLine).toBe(1); + expect(result!.endLine).toBe(3); + expect(result!.appliedIndentDelta).toBe(4); + }); + + it('outdenting a parent drags direct and deep descendants', () => { + const lines = [ + '- root', + ' - parent', + ' - child', + ' - grandchild', + '- sibling', + ]; + + const result = moveOutlinerBranch(lines, 1, 4, 'outdent'); + + expect(result).not.toBeNull(); + expect(result!.lines).toEqual([ + '- root', + '- parent', + ' - child', + ' - grandchild', + '- sibling', + ]); + expect(result!.startLine).toBe(1); + expect(result!.endLine).toBe(3); + expect(result!.appliedIndentDelta).toBe(-4); + }); + + it('outdenting a nested branch clamps at root level and preserves subtree shape', () => { + const lines = [ + '- parent', + ' - child', + ' - grandchild', + ]; + + const result = moveOutlinerBranch(lines, 0, 4, 'outdent'); + + expect(result).not.toBeNull(); + expect(result!.lines).toEqual(lines); + expect(result!.appliedIndentDelta).toBe(0); + }); + + it('leaves sibling branches outside the moved subtree unchanged', () => { + const lines = [ + '- root', + ' - branch a', + ' - child a1', + ' - branch b', + ' - child b1', + ]; + + const result = moveOutlinerBranch(lines, 1, 4, 'outdent'); + + expect(result).not.toBeNull(); + expect(result!.lines).toEqual([ + '- root', + '- branch a', + ' - child a1', + ' - branch b', + ' - child b1', + ]); + }); + + it('moves continuation paragraphs, fenced blocks, and tables with the branch', () => { + const lines = [ + '- root', + ' - parent', + ' continuation paragraph', + ' ```ts', + ' const water = true;', + ' ```', + ' | Col | Val |', + ' | --- | --- |', + ' | A | B |', + ' - child', + '- sibling', + ]; + + const result = moveOutlinerBranch(lines, 1, 4, 'outdent'); + + expect(result).not.toBeNull(); + expect(result!.lines).toEqual([ + '- root', + '- parent', + ' continuation paragraph', + ' ```ts', + ' const water = true;', + ' ```', + ' | Col | Val |', + ' | --- | --- |', + ' | A | B |', + ' - child', + '- sibling', + ]); + expect(result!.endLine).toBe(9); + }); +}); + +// ── getOutlinerFenceContentBoundary ───────────────────────────────────────── + +describe('getOutlinerFenceContentBoundary', () => { + it('returns contentIndent for a content line inside a bullet fence', () => { + const lines = [ + '- ```ts', + ' const x = 1;', + ' ```', + ]; + expect(getOutlinerFenceContentBoundary(lines, 1)).toBe(2); + }); + + it('returns null for the opener line itself', () => { + const lines = [ + '- ```ts', + ' const x = 1;', + ' ```', + ]; + expect(getOutlinerFenceContentBoundary(lines, 0)).toBeNull(); + }); + + it('returns null for the closer line', () => { + const lines = [ + '- ```ts', + ' const x = 1;', + ' ```', + ]; + expect(getOutlinerFenceContentBoundary(lines, 2)).toBeNull(); + }); + + it('returns null for a line outside any fence', () => { + const lines = [ + '- plain bullet', + ' continuation', + ]; + expect(getOutlinerFenceContentBoundary(lines, 0)).toBeNull(); + expect(getOutlinerFenceContentBoundary(lines, 1)).toBeNull(); + }); + + it('returns correct boundary for indented bullet fence', () => { + const lines = [ + '- parent', + ' - ```js', + ' let y = 2;', + ' ```', + ]; + expect(getOutlinerFenceContentBoundary(lines, 2)).toBe(6); + }); + + it('returns boundary for blank lines inside a fence', () => { + const lines = [ + '- ```', + '', + ' ```', + ]; + expect(getOutlinerFenceContentBoundary(lines, 1)).toBe(2); + }); +}); + +// ── isOutlinerFenceBackspaceBlocked ──────────────────────────────────────── + +describe('isOutlinerFenceBackspaceBlocked', () => { + it('blocks when cursor is at column 0 inside a fence', () => { + expect(isOutlinerFenceBackspaceBlocked('code', 0, 2)).toBe(true); + }); + + it('blocks when cursor is at the boundary and indent is at boundary', () => { + expect(isOutlinerFenceBackspaceBlocked(' code', 2, 2)).toBe(true); + }); + + it('blocks when cursor is before boundary on a line at boundary indent', () => { + expect(isOutlinerFenceBackspaceBlocked(' code', 1, 2)).toBe(true); + }); + + it('allows when cursor is past the boundary', () => { + expect(isOutlinerFenceBackspaceBlocked(' code', 3, 2)).toBe(false); + }); + + it('allows when line indent is above boundary and cursor is at boundary', () => { + // Line has 4 spaces indent, boundary is 2. Deleting at col 2 still leaves indent >= 2. + expect(isOutlinerFenceBackspaceBlocked(' code', 2, 2)).toBe(false); + }); + + it('blocks when line indent equals boundary and cursor is within indent', () => { + expect(isOutlinerFenceBackspaceBlocked(' code', 2, 2)).toBe(true); + }); + + it('blocks when line is blank and cursor is at 0', () => { + expect(isOutlinerFenceBackspaceBlocked('', 0, 2)).toBe(true); + }); + + it('blocks when line has less indent than boundary and cursor is at 0', () => { + expect(isOutlinerFenceBackspaceBlocked(' x', 0, 2)).toBe(true); + }); +}); + +// ── canJoinOutlinerFenceContentWithPreviousLine ─────────────────────────── + +describe('canJoinOutlinerFenceContentWithPreviousLine', () => { + it('returns true when the previous line is content in the same bullet-owned fence', () => { + const lines = [ + '- ```ts', + ' const a = 1;', + ' const b = 2;', + ' ```', + ]; + + expect(canJoinOutlinerFenceContentWithPreviousLine(lines, 2)).toBe(true); + }); + + it('returns false when the previous line is the fence opener', () => { + const lines = [ + '- ```ts', + ' const a = 1;', + ' ```', + ]; + + expect(canJoinOutlinerFenceContentWithPreviousLine(lines, 1)).toBe(false); + }); + + it('returns false when the line is not inside bullet-owned fence content', () => { + const lines = [ + '- plain bullet', + ' continuation', + ]; + + expect(canJoinOutlinerFenceContentWithPreviousLine(lines, 1)).toBe(false); + }); + + it('returns false when the previous line is outside the same fence content region', () => { + const lines = [ + '- ```ts', + ' const a = 1;', + ' ```', + '- next bullet', + ]; + + expect(canJoinOutlinerFenceContentWithPreviousLine(lines, 3)).toBe(false); + }); +}); + +// ── shiftOutlinerFenceContentLine ───────────────────────────────────────── + +describe('shiftOutlinerFenceContentLine', () => { + it('indents a fence-content line by one tab stop', () => { + expect(shiftOutlinerFenceContentLine(' code', 4, 'indent', 2)).toEqual({ + lineText: ' code', + appliedIndentDelta: 4, + }); + }); + + it('outdents a fence-content line but clamps at the boundary', () => { + expect(shiftOutlinerFenceContentLine(' code', 4, 'outdent', 4)).toEqual({ + lineText: ' code', + appliedIndentDelta: -2, + }); + }); + + it('does not outdent past the boundary', () => { + expect(shiftOutlinerFenceContentLine(' code', 4, 'outdent', 4)).toEqual({ + lineText: ' code', + appliedIndentDelta: 0, + }); + }); + + it('leaves already-invalid left-shifted lines unchanged on outdent', () => { + expect(shiftOutlinerFenceContentLine(' code', 4, 'outdent', 4)).toEqual({ + lineText: ' code', + appliedIndentDelta: 0, + }); + }); + + it('indents blank fence-content lines', () => { + expect(shiftOutlinerFenceContentLine(' ', 2, 'indent', 4)).toEqual({ + lineText: ' ', + appliedIndentDelta: 2, + }); + }); +}); + +// ── getOutlinerFenceVerticalMoveTarget ──────────────────────────────────── + +describe('getOutlinerFenceVerticalMoveTarget', () => { + it('clamps the target cursor column to the boundary', () => { + expect(getOutlinerFenceVerticalMoveTarget(' code', 0, 2)).toEqual({ + lineText: ' code', + cursorCharacter: 2, + }); + }); + + it('preserves a preferred column to the right of the boundary', () => { + expect(getOutlinerFenceVerticalMoveTarget(' code', 4, 2)).toEqual({ + lineText: ' code', + cursorCharacter: 4, + }); + }); + + it('pads a blank line so the cursor can land at the boundary', () => { + expect(getOutlinerFenceVerticalMoveTarget('', 0, 2)).toEqual({ + lineText: ' ', + cursorCharacter: 2, + }); + }); + + it('pads whitespace-only lines up to the boundary', () => { + expect(getOutlinerFenceVerticalMoveTarget(' ', 0, 3)).toEqual({ + lineText: ' ', + cursorCharacter: 3, + }); + }); + + it('pads short lines up to the boundary before placing the cursor', () => { + expect(getOutlinerFenceVerticalMoveTarget('x', 0, 3)).toEqual({ + lineText: 'x ', + cursorCharacter: 3, + }); + }); +}); + +// ── formatOutlinerFencePaste ─────────────────────────────────────────────── + +describe('formatOutlinerFencePaste', () => { + it('rebases pasted text so minimum indent lands on the boundary', () => { + const result = formatOutlinerFencePaste(4, 'if (x) {\n y();\n}'); + expect(result).toBe(' if (x) {\n y();\n }'); + }); + + it('preserves relative indentation within pasted code', () => { + const result = formatOutlinerFencePaste(2, ' a\n b\n c'); + expect(result).toBe(' a\n b\n c'); + }); + + it('leaves blank lines blank', () => { + const result = formatOutlinerFencePaste(2, 'line1\n\nline2'); + expect(result).toBe(' line1\n\n line2'); + }); + + it('handles single-line paste by adding boundary indent', () => { + const result = formatOutlinerFencePaste(4, 'hello'); + expect(result).toBe(' hello'); + }); + + it('normalises CRLF to LF', () => { + const result = formatOutlinerFencePaste(2, 'a\r\nb'); + expect(result).toBe(' a\n b'); + }); + + it('handles already-indented paste that exceeds boundary', () => { + // Minimum indent is 6, boundary is 4. Should shift left by 2. + const result = formatOutlinerFencePaste(4, ' x\n y'); + expect(result).toBe(' x\n y'); + }); + + it('handles paste with no indentation at boundary 0', () => { + const result = formatOutlinerFencePaste(0, 'a\n b'); + expect(result).toBe('a\n b'); + }); +}); diff --git a/vs-code-extension/src/test/WikilinkCodeSuppression.test.ts b/vs-code-extension/src/test/WikilinkCodeSuppression.test.ts new file mode 100644 index 0000000..dfd27e7 --- /dev/null +++ b/vs-code-extension/src/test/WikilinkCodeSuppression.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => { + class Position { + constructor(public line: number, public character: number) { } + } + + class Range { + constructor(public start: Position, public end: Position) { } + } + + class CompletionItem { + detail?: string; + sortText?: string; + filterText?: string; + insertText?: string; + range?: Range; + + constructor(public label: string, public kind?: number) { } + } + + class CompletionList { + constructor(public items: CompletionItem[], public isIncomplete?: boolean) { } + } + + return { + Position, + Range, + CompletionItem, + CompletionList, + CompletionItemKind: { + File: 0, + Reference: 1, + }, + }; +}); + +import * as vscode from 'vscode'; +import { WikilinkCompletionProvider } from '../WikilinkCompletionProvider.js'; + +describe('Wikilink code suppression', () => { + it('suppresses completion inside inline code', () => { + const provider = new WikilinkCompletionProvider( + { isOpen: false } as never, + { time: () => () => { }, info: () => { } } as never, + ); + + const document = { + lineCount: 1, + lineAt: (line: number) => ({ + text: line === 0 ? 'Some `[[Demo` code' : '', + }), + } as vscode.TextDocument; + + const result = provider.provideCompletionItems( + document, + new vscode.Position(0, 12), + {} as vscode.CancellationToken, + {} as vscode.CompletionContext, + ); + + expect(result).toBeUndefined(); + }); + + it('suppresses completion inside bullet-owned fenced code blocks', () => { + const provider = new WikilinkCompletionProvider( + { isOpen: false } as never, + { time: () => () => { }, info: () => { } } as never, + ); + + const lines = ['- ```', ' [[Demo', ' ```', '- after']; + const document = { + lineCount: lines.length, + lineAt: (line: number) => ({ + text: lines[line] ?? '', + }), + } as vscode.TextDocument; + + const result = provider.provideCompletionItems( + document, + new vscode.Position(1, 8), + {} as vscode.CancellationToken, + {} as vscode.CompletionContext, + ); + + expect(result).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/vs-code-extension/src/test/WikilinkCompletionProvider.test.ts b/vs-code-extension/src/test/WikilinkCompletionProvider.test.ts index 9cca30d..7cefeb1 100644 --- a/vs-code-extension/src/test/WikilinkCompletionProvider.test.ts +++ b/vs-code-extension/src/test/WikilinkCompletionProvider.test.ts @@ -279,6 +279,12 @@ describe('WikilinkCompletionProvider — isPositionInsideCode', () => { expect(check(lines, 4, 0)).toBe(false); }); + it('should treat bullet-owned fenced blocks as code', () => { + const lines = ['- ```', ' [[Demo]]', ' ```', '- after']; + expect(check(lines, 1, 2)).toBe(true); + expect(check(lines, 3, 0)).toBe(false); + }); + it('should return false for empty document', () => { expect(check([], 0, 0)).toBe(false); }); diff --git a/vs-code-extension/src/test/WikilinkRenameTracker.test.ts b/vs-code-extension/src/test/WikilinkRenameTracker.test.ts index 3512d8c..af6ce27 100644 --- a/vs-code-extension/src/test/WikilinkRenameTracker.test.ts +++ b/vs-code-extension/src/test/WikilinkRenameTracker.test.ts @@ -149,6 +149,35 @@ describe('WikilinkRenameTracker — debounce guard integration', () => { }); }); +describe('WikilinkRenameTracker — code suppression', () => { + it('does not create pending rename state for edits inside inline code', () => { + const tracker = makeTracker(); + const document = { + languageId: 'markdown', + uri: { fsPath: 'notes/page.md', toString: () => 'file:///notes/page.md' }, + lineCount: 1, + lineAt: () => ({ text: 'Some `[[Demo` code' }), + } as unknown as vscode.TextDocument; + + (vscode.workspace.asRelativePath as unknown as ReturnType).mockReturnValue('notes/page.md'); + const activeTextEditor = { + document, + selection: { active: { line: 0, character: 12 } }, + }; + (vscode.window as unknown as { activeTextEditor: unknown }).activeTextEditor = activeTextEditor; + + const indexService = (tracker as unknown as { indexService: { getPageByPath: ReturnType } }).indexService; + indexService.getPageByPath.mockReturnValue({ id: 1, path: 'notes/page.md' }); + + (tracker as unknown as { onDocumentChanged: (event: vscode.TextDocumentChangeEvent) => void }).onDocumentChanged({ + document, + contentChanges: [{ text: 'x' }], + } as vscode.TextDocumentChangeEvent); + + expect(tracker.hasPendingEdit('file:///notes/page.md')).toBe(false); + }); +}); + // ── isNestingChange ─────────────────────────────────────────────────────────── describe('WikilinkRenameTracker.isNestingChange', () => {