Skip to content

Commit 594abb5

Browse files
committed
Rename refactoring working with expensive full scan
1 parent 31df584 commit 594abb5

9 files changed

Lines changed: 578 additions & 136 deletions

TECHNICAL.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,7 @@ The `sortText` prefix ensures pages always appear before aliases in the list. Bo
10881088
10891089
**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.
10901090
1091-
All four pure functions — `findInnermostOpenBracket()`, `findMatchingCloseBracket()`, `isLineInsideFrontMatter()`, and `isPositionInsideCode()` — live in `CompletionUtils.ts` with no VS Code dependency, and are fully unit-tested.
1091+
All five pure functions — `findInnermostOpenBracket()`, `findMatchingCloseBracket()`, `isLineInsideFrontMatter()`, `isPositionInsideCode()`, and `hasNewCompleteWikilink()` — live in `CompletionUtils.ts` with no VS Code dependency, and are fully unit-tested.
10921092
10931093
**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.
10941094
@@ -1097,7 +1097,7 @@ All four pure functions — `findInnermostOpenBracket()`, `findMatchingCloseBrac
10971097
The provider maintains a cached array of `CompletionItem[]` objects. The cache is rebuilt **eagerly** whenever `refresh()` is called — there is no dirty flag or lazy rebuild.
10981098
10991099
- **Eager rebuild:** `refresh()` calls `rebuildCache()` immediately, running three SQLite queries (`getAllPages`, `getAllAliases`, `getForwardReferencedPages`) and building lightweight plain data objects (not `CompletionItem` instances). This happens at index-update time (file save, create, delete, rename, periodic scan, rebuild), not at `[[` keystroke time.
1100-
- **Not called on text-change debounce:** The 500ms `onDidChangeTextDocument` debounce handler does **not** call `completionProvider.refresh()`. This eliminates three SQLite queries on every typing pause. Forward references appear in autocomplete after the next file save.
1100+
- **Selective text-change refresh:** The 500 ms `onDidChangeTextDocument` debounce handler re-indexes the live buffer and calls `completionProvider.refresh()` **only when** `hasNewCompleteWikilink()` detects that the current document now contains at least one newly added complete wikilink compared to the page's last indexed links. This makes new forward references available without save while avoiding the refresh cost on ordinary typing edits.
11011101
- **Warm on activation:** `enterFullMode()` calls `completionProvider.refresh()` immediately after construction (post stale-scan), so the cache is warm before the user types the first `[[`.
11021102
- **Not called on editor switch:** The `onDidChangeActiveTextEditor` handler does **not** call `completionProvider.refresh()`. Completion data is global (all pages, aliases, forward refs) and is unaffected by which tab is focused. This avoids 3 SQLite queries on every tab switch.
11031103
- **Hot path:** `provideCompletionItems()` never touches the database. It builds `CompletionItem` instances from lightweight cached data objects with the per-call replacement range and returns them inside a `CompletionList`.
@@ -1128,11 +1128,11 @@ The listener only fires on deletions (where `rangeLength > 0` and `text` is empt
11281128
When the user types inside a wikilink, two things happen on every keystroke:
11291129
11301130
1. `WikilinkRenameTracker.onDocumentChanged` — records `pendingEdit` (the outermost wikilink position the cursor is inside). This edit state is cleared and rename detection runs when the cursor exits the wikilink.
1131-
2. The 500 ms `onDidChangeTextDocument` debounce in `extension.ts` — re-indexes the live buffer into the in-memory DB so that newly typed forward references appear in autocomplete immediately.
1131+
2. The 500 ms `onDidChangeTextDocument` debounce in `extension.ts` — re-indexes the live buffer into the in-memory DB and selectively refreshes autocomplete when a newly added complete wikilink is detected.
11321132
11331133
These two behaviours interact at the index: rename detection (`checkForRenames`) works by comparing the **current DB state** (the last-indexed link positions and names) against the live document. If the debounce fires and re-indexes the document before the cursor exits the wikilink, the DB is updated to reflect the edited name. When `checkForRenames` later runs after cursor exit, it compares the edited name in the DB against the same name in the live document — no difference is detected, and the rename dialog is never shown.
11341134
1135-
**Guard:** The debounce callback in `extension.ts` checks `renameTracker.hasPendingEdit(doc.uri.toString())` before calling `indexFileContent`. If a pending edit is active for that document, the re-index is skipped for that tick. `WikilinkRenameTracker` exposes:
1135+
**Guard:** The debounce callback in `extension.ts` checks `renameTracker.hasPendingEdit(doc.uri.toString())` before calling `indexFileContent` or refreshing completion. If a pending edit is active for that document, the re-index is skipped for that tick. `WikilinkRenameTracker` exposes:
11361136
11371137
```typescript
11381138
hasPendingEdit(docKey: string): boolean
@@ -1594,6 +1594,16 @@ If `resolveAlias(oldPageName)` returns the same page whose filename is `oldPageN
15941594
15951595
`updateLinksInWorkspace()` finds all `.md` and `.markdown` files, parses each for wikilinks, and creates a `WorkspaceEdit` that replaces every `[[oldPageName]]` with `[[newPageName]]`. After applying the edit, it saves modified files.
15961596
1597+
**Notification progress:**
1598+
1599+
Accepted in-editor rename operations are wrapped in `withWikilinkRenameProgress()` (`WikilinkRenameProgressService.ts`), which shows a non-cancellable VS Code notification while the slow path runs. The tracker reports three coarse phases:
1600+
1601+
1. `Preparing rename operations`
1602+
2. `Updating links across workspace`
1603+
3. `Refreshing index`
1604+
1605+
The progress notification is only shown after the user confirms the rename or merge. Declined or dismissed prompts remain a no-op aside from the existing decline re-index path.
1606+
15971607
### Post-rename index refresh
15981608
15991609
After a rename operation completes, `refreshIndexAfterRename()` ensures the index is consistent before releasing control:
@@ -1618,6 +1628,12 @@ After the renamed file is indexed at its new path, AS Notes checks for filename
16181628
16191629
This selection logic is isolated in `WikilinkExplorerMergeService.ts` so the ambiguity rules are unit-tested independently of the large `extension.ts` event handler.
16201630
1631+
The user-confirmed refactor work that follows explorer renames is now extracted into `WikilinkExplorerRenameRefactorService.ts`. That helper applies the same notification UX as in-editor renames:
1632+
1633+
1. Accepted merge operations show `AS Notes: Applying rename updates` while the merge, delete, and target re-index complete.
1634+
2. Accepted workspace-wide reference updates show `AS Notes: Updating wikilink references` while link replacement and stale-scan refresh complete.
1635+
3. Declined explorer prompts do not show progress notifications.
1636+
16211637
### Re-entrancy guard
16221638
16231639
The `isProcessing` flag prevents document-change events fired by the rename operation itself (file renames, workspace edits) from being treated as new user edits. It is set to `true` before any rename work begins and cleared in the `finally` block.

vs-code-extension/src/CompletionUtils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import type { IWikilinkService } from 'as-notes-common';
2+
13
/**
24
* Pure utility functions for wikilink completion logic.
35
* No VS Code dependencies — safe for unit testing.
46
*/
57

8+
interface IndexedLinkLike {
9+
page_name: string;
10+
}
11+
612
/**
713
* Find the column of the innermost unclosed `[[` in the text up to the cursor.
814
* Returns -1 if no valid `[[` trigger is found.
@@ -147,3 +153,38 @@ export function isPositionInsideCode(lines: string[], lineIndex: number, charInd
147153

148154
return false;
149155
}
156+
157+
/**
158+
* Determine whether the current document contains at least one newly added
159+
* complete wikilink compared to the page's last indexed links.
160+
*
161+
* This uses page-name occurrence counts rather than positions so that plain
162+
* text edits that shift existing links do not count as new links, while added
163+
* duplicate links and nested links are still detected.
164+
*/
165+
export function hasNewCompleteWikilink(
166+
lines: string[],
167+
indexedLinks: IndexedLinkLike[],
168+
wikilinkService: IWikilinkService,
169+
): boolean {
170+
const indexedCounts = new Map<string, number>();
171+
for (const link of indexedLinks) {
172+
indexedCounts.set(link.page_name, (indexedCounts.get(link.page_name) ?? 0) + 1);
173+
}
174+
175+
const currentCounts = new Map<string, number>();
176+
for (const line of lines) {
177+
const wikilinks = wikilinkService.extractWikilinks(line, false, false);
178+
for (const wikilink of wikilinks) {
179+
currentCounts.set(wikilink.pageName, (currentCounts.get(wikilink.pageName) ?? 0) + 1);
180+
}
181+
}
182+
183+
for (const [pageName, count] of currentCounts) {
184+
if (count > (indexedCounts.get(pageName) ?? 0)) {
185+
return true;
186+
}
187+
}
188+
189+
return false;
190+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import * as path from 'path';
2+
import * as vscode from 'vscode';
3+
import { FrontMatterService, WikilinkService } from 'as-notes-common';
4+
import type { IndexScanner } from './IndexScanner.js';
5+
import type { IndexService } from './IndexService.js';
6+
import {
7+
getExistingExplorerMergeTargets,
8+
pickUniqueExplorerMergeTarget,
9+
} from './WikilinkExplorerMergeService.js';
10+
import { toNotesRelativePath } from './NotesRootService.js';
11+
import { updateLinksInWorkspace } from './WikilinkRefactorService.js';
12+
import { withWikilinkRenameProgress } from './WikilinkRenameProgressService.js';
13+
14+
interface ExplorerRenameFile {
15+
oldUri: vscode.Uri;
16+
newUri: vscode.Uri;
17+
}
18+
19+
interface ExplorerRenameRefactorDeps {
20+
files: ExplorerRenameFile[];
21+
renameTrackerIsRenaming: boolean;
22+
wikilinkService: WikilinkService;
23+
indexService: Pick<IndexService, 'findPagesByFilename' | 'removePage'>;
24+
indexScanner: Pick<IndexScanner, 'staleScan' | 'indexFile'>;
25+
notesRootPath?: string;
26+
safeSaveToFile: () => boolean;
27+
refreshProviders: () => void;
28+
}
29+
30+
function isMarkdownUri(uri: vscode.Uri): boolean {
31+
const ext = path.extname(uri.fsPath).toLowerCase();
32+
return ext === '.md' || ext === '.markdown';
33+
}
34+
35+
export async function handleExplorerRenameRefactors({
36+
files,
37+
renameTrackerIsRenaming,
38+
wikilinkService,
39+
indexService,
40+
indexScanner,
41+
notesRootPath,
42+
safeSaveToFile,
43+
refreshProviders,
44+
}: ExplorerRenameRefactorDeps): Promise<void> {
45+
if (renameTrackerIsRenaming) { return; }
46+
47+
const linkRenames: { oldPageName: string; newPageName: string }[] = [];
48+
49+
for (const { oldUri, newUri } of files) {
50+
if (!isMarkdownUri(newUri)) { continue; }
51+
const newFilename = path.basename(newUri.fsPath);
52+
const pages = indexService.findPagesByFilename(newFilename);
53+
if (pages.length < 2) { continue; }
54+
55+
const newPath = notesRootPath
56+
? toNotesRelativePath(notesRootPath, newUri.fsPath)
57+
: vscode.workspace.asRelativePath(newUri, false);
58+
const existingTargets = getExistingExplorerMergeTargets(pages, newPath);
59+
if (existingTargets.length === 0) { continue; }
60+
if (existingTargets.length > 1) {
61+
vscode.window.showWarningMessage(
62+
`Merge skipped for "${newFilename}": multiple existing targets match this filename.`,
63+
);
64+
continue;
65+
}
66+
67+
const existingPage = pickUniqueExplorerMergeTarget(pages, newPath);
68+
if (!existingPage) { continue; }
69+
70+
const rootUri = notesRootPath
71+
? vscode.Uri.file(notesRootPath)
72+
: vscode.workspace.workspaceFolders?.[0]?.uri;
73+
if (!rootUri) { continue; }
74+
75+
const mergeChoice = await vscode.window.showInformationMessage(
76+
`Merge "${newFilename}" into existing "${existingPage.path}"?`,
77+
'Yes', 'No',
78+
);
79+
if (mergeChoice === 'Yes') {
80+
await withWikilinkRenameProgress('AS Notes: Applying rename updates', async (progress) => {
81+
progress.report('Merging renamed page');
82+
const targetUri = vscode.Uri.joinPath(rootUri, existingPage.path);
83+
const sourceDoc = await vscode.workspace.openTextDocument(newUri);
84+
const targetDoc = await vscode.workspace.openTextDocument(targetUri);
85+
86+
const mergedContent = new FrontMatterService().mergeDocuments(
87+
targetDoc.getText(),
88+
sourceDoc.getText(),
89+
);
90+
91+
const edit = new vscode.WorkspaceEdit();
92+
const fullRange = new vscode.Range(
93+
targetDoc.lineAt(0).range.start,
94+
targetDoc.lineAt(targetDoc.lineCount - 1).range.end,
95+
);
96+
edit.replace(targetUri, fullRange, mergedContent);
97+
await vscode.workspace.applyEdit(edit);
98+
await targetDoc.save();
99+
100+
progress.report('Refreshing index');
101+
await vscode.workspace.fs.delete(newUri);
102+
indexService.removePage(newPath);
103+
try { await indexScanner.indexFile(targetUri); } catch { /* best effort */ }
104+
safeSaveToFile();
105+
});
106+
}
107+
}
108+
109+
for (const { oldUri, newUri } of files) {
110+
if (isMarkdownUri(oldUri) && isMarkdownUri(newUri)) {
111+
const oldExt = path.extname(oldUri.fsPath);
112+
const newExt = path.extname(newUri.fsPath);
113+
const oldPageName = path.basename(oldUri.fsPath, oldExt);
114+
const newPageName = path.basename(newUri.fsPath, newExt);
115+
if (oldPageName !== newPageName) {
116+
linkRenames.push({ oldPageName, newPageName });
117+
}
118+
}
119+
}
120+
121+
if (linkRenames.length === 0) { return; }
122+
123+
const summary = linkRenames
124+
.map(r => `[[${r.oldPageName}]] → [[${r.newPageName}]]`)
125+
.join(', ');
126+
const msg = linkRenames.length === 1
127+
? `Update all ${summary} references?`
128+
: `Update references for ${linkRenames.length} renamed files? ${summary}`;
129+
const choice = await vscode.window.showInformationMessage(msg, 'Yes', 'No');
130+
if (choice !== 'Yes') { return; }
131+
132+
await withWikilinkRenameProgress('AS Notes: Updating wikilink references', async (progress) => {
133+
progress.report('Updating links across workspace');
134+
await updateLinksInWorkspace(wikilinkService, linkRenames);
135+
136+
progress.report('Refreshing index');
137+
try { await indexScanner.staleScan(); } catch { /* best effort */ }
138+
if (safeSaveToFile()) {
139+
refreshProviders();
140+
}
141+
});
142+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as vscode from 'vscode';
2+
3+
export interface RenameProgressReporter {
4+
report(message: string): void;
5+
}
6+
7+
export async function withWikilinkRenameProgress<T>(
8+
title: string,
9+
task: (progress: RenameProgressReporter) => Promise<T>,
10+
): Promise<T> {
11+
return vscode.window.withProgress(
12+
{
13+
location: vscode.ProgressLocation.Notification,
14+
title,
15+
cancellable: false,
16+
},
17+
async (progress) => task({
18+
report(message: string) {
19+
progress.report({ message });
20+
},
21+
}),
22+
);
23+
}

vs-code-extension/src/WikilinkRenameTracker.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { sanitiseFileName } from './PathUtils.js';
77
import { FrontMatterService } from 'as-notes-common';
88
import { toNotesRelativePath } from './NotesRootService.js';
99
import { updateLinksInWorkspace } from './WikilinkRefactorService.js';
10+
import { withWikilinkRenameProgress } from './WikilinkRenameProgressService.js';
1011

1112
/**
1213
* Detected rename: a wikilink at the same position now has a different pageName.
@@ -465,35 +466,39 @@ export class WikilinkRenameTracker implements vscode.Disposable {
465466

466467
this.isProcessing = true;
467468
try {
468-
// Process file merges (target already exists — merge content)
469-
for (const fm of fileMerges) {
470-
await this.mergeFiles(fm.oldUri, fm.newUri);
471-
}
469+
await withWikilinkRenameProgress('AS Notes: Applying rename updates', async (progress) => {
470+
progress.report('Preparing rename operations');
472471

473-
// Process direct file renames
474-
for (const fr of fileRenames) {
475-
await vscode.workspace.fs.rename(fr.oldUri, fr.newUri, { overwrite: false });
476-
}
472+
// Process file merges (target already exists — merge content)
473+
for (const fm of fileMerges) {
474+
await this.mergeFiles(fm.oldUri, fm.newUri);
475+
}
477476

478-
// Process alias renames — update front matter on canonical pages
479-
for (const r of classifiedRenames) {
480-
if (r.isAlias && r.canonicalPagePath) {
481-
await this.updateAliasFrontMatter(
482-
r.canonicalPagePath,
483-
r.oldPageName,
484-
r.newPageName,
485-
);
477+
// Process direct file renames
478+
for (const fr of fileRenames) {
479+
await vscode.workspace.fs.rename(fr.oldUri, fr.newUri, { overwrite: false });
486480
}
487-
}
488481

489-
// Update links across the workspace (outermost first)
490-
await updateLinksInWorkspace(
491-
this.wikilinkService,
492-
renames.map(r => ({ oldPageName: r.oldPageName, newPageName: r.newPageName })),
493-
);
482+
// Process alias renames — update front matter on canonical pages
483+
for (const r of classifiedRenames) {
484+
if (r.isAlias && r.canonicalPagePath) {
485+
await this.updateAliasFrontMatter(
486+
r.canonicalPagePath,
487+
r.oldPageName,
488+
r.newPageName,
489+
);
490+
}
491+
}
492+
493+
progress.report('Updating links across workspace');
494+
await updateLinksInWorkspace(
495+
this.wikilinkService,
496+
renames.map(r => ({ oldPageName: r.oldPageName, newPageName: r.newPageName })),
497+
);
494498

495-
// Refresh the index
496-
await this.refreshIndexAfterRename(document, classifiedRenames, fileRenames);
499+
progress.report('Refreshing index');
500+
await this.refreshIndexAfterRename(document, classifiedRenames, fileRenames);
501+
});
497502
} catch (err) {
498503
const detail = err instanceof Error ? err.message : String(err);
499504
vscode.window.showErrorMessage(`Rename failed: ${detail}`);

0 commit comments

Comments
 (0)