Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { toast, Toaster } from 'sonner';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { parseCodePath } from '@plannotator/shared/code-file';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
import { HtmlViewer } from '@plannotator/ui/components/html-viewer';
Expand Down Expand Up @@ -59,8 +60,10 @@ import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotat
import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights';
import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions';
import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser';
import { useValidatedCodePaths, type ValidationEntry } from '@plannotator/ui/hooks/useValidatedCodePaths';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser';
import { extractPlanContextFiles, type PlanContextFile } from '@plannotator/ui/utils/planContext';
import { generateId } from '@plannotator/ui/utils/generateId';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
Expand Down Expand Up @@ -354,6 +357,42 @@ const App: React.FC = () => {
}, [activeDocBaseDir]),
});

const rawPlanContextFiles = useMemo(
() => extractPlanContextFiles(blocks),
[blocks],
);
const planContextValidation = useValidatedCodePaths(markdown);
const planContextValidationByPath = useMemo(() => {
const next = new Map<string, ValidationEntry>();
for (const [candidate, validation] of planContextValidation.validated) {
const path = parseCodePath(candidate).filePath;
const existing = next.get(path);
if (!existing || validation.status === 'found') {
next.set(path, validation);
}
}
return next;
}, [planContextValidation.validated]);
const planContextFiles = useMemo<PlanContextFile[]>(
() => rawPlanContextFiles.map((file) => {
if (!planContextValidation.ready) return file;
const validation = planContextValidationByPath.get(file.path);
if (!validation) return file;
if (validation.status === 'found') {
return {
...file,
resolvedPath: validation.resolved,
validationStatus: validation.status,
};
}
return {
...file,
validationStatus: validation.status,
};
}),
[rawPlanContextFiles, planContextValidation.ready, planContextValidationByPath],
);

// Archive browser
const archive = useArchive({
markdown, viewerRef, linkedDocHook,
Expand All @@ -365,6 +404,22 @@ const App: React.FC = () => {
isPlanDiffActive,
}), [archive.archiveMode, isPlanDiffActive]);

const showContextTab = useMemo(
() => planContextFiles.length > 0
&& !archive.archiveMode
&& !annotateMode
&& !linkedDocHook.isActive
&& renderAs === 'markdown'
&& !isPlanDiffActive,
[planContextFiles.length, archive.archiveMode, annotateMode, linkedDocHook.isActive, renderAs, isPlanDiffActive],
);

useEffect(() => {
if (sidebar.isOpen && sidebar.activeTab === 'context' && !showContextTab) {
sidebar.open('toc');
}
}, [sidebar.isOpen, sidebar.activeTab, sidebar.open, showContextTab]);

const enterViewMode = useCallback((type: WideModeType) => {
if (!canUseWideMode) return;
if (wideModeType === null) {
Expand Down Expand Up @@ -1280,6 +1335,22 @@ const App: React.FC = () => {
// This is just a placeholder for future custom logic
};

const handleContextNavigate = useCallback((blockId: string) => {
const target = document.querySelector(`[data-block-id="${blockId}"]`);
if (!target || !scrollViewport) return;

const headerOffset = 80;
const containerRect = scrollViewport.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const offsetPosition =
scrollViewport.scrollTop + targetRect.top - containerRect.top - headerOffset;

scrollViewport.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}, [scrollViewport]);

const annotationsOutput = useMemo(() => {
const docAnnotations = linkedDocHook.getDocAnnotations();
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
Expand Down Expand Up @@ -1697,6 +1768,7 @@ const App: React.FC = () => {
onToggleTab={toggleSidebarTab}
hasDiff={planDiff.hasPreviousVersion}
showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1}
showContextTab={showContextTab}
showFilesTab={showFilesTab && !archive.archiveMode}
hasFileAnnotations={hasFileAnnotations}
className="hidden lg:flex absolute left-0 top-0 z-10"
Expand All @@ -1721,6 +1793,9 @@ const App: React.FC = () => {
linkedDocFilepath={linkedDocHook.filepath}
onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined}
backLabel={backLabel}
showContextTab={showContextTab}
planContextFiles={planContextFiles}
onContextNavigate={handleContextNavigate}
showFilesTab={showFilesTab && !archive.archiveMode}
fileAnnotationCounts={fileAnnotationCounts}
highlightedFiles={highlightedFiles}
Expand Down
7 changes: 6 additions & 1 deletion packages/shared/extract-code-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { extractCandidateCodePaths } from "./extract-code-paths";
import { extractCandidateCodePathMentions, extractCandidateCodePaths } from "./extract-code-paths";

describe("extractCandidateCodePaths", () => {
test("extracts backtick code-file paths", () => {
Expand All @@ -19,6 +19,11 @@ describe("extractCandidateCodePaths", () => {
expect(extractCandidateCodePaths(md)).toEqual(["src/foo.ts"]);
});

test("keeps repeated references for mention summaries", () => {
const md = "`src/foo.ts` and src/foo.ts again";
expect(extractCandidateCodePathMentions(md)).toEqual(["src/foo.ts", "src/foo.ts"]);
});

test("strips line anchors", () => {
const md = "see `src/foo.ts#L42`";
expect(extractCandidateCodePaths(md)).toEqual(["src/foo.ts"]);
Expand Down
39 changes: 33 additions & 6 deletions packages/shared/extract-code-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,37 @@ const BACKTICK_SPAN = /`([^`\n]+)`/g;
* Hash anchors (`#L42`) are stripped from results to match the renderer's
* `cleanPath` transform. Returns deduped candidate strings.
*/
export function extractCandidateCodePaths(markdown: string): string[] {
function collectCandidateCodePaths(markdown: string, dedupe: boolean): string[] {
const stripped = markdown
.replace(FENCED_CODE_BLOCK, "")
.replace(HTML_COMMENT, "");

const candidates = new Set<string>();
const seen = new Set<string>();
const candidates: string[] = [];

const addCandidate = (candidate: string) => {
const clean = candidate.replace(/#.*$/, "");
if (dedupe) {
if (seen.has(clean)) return;
seen.add(clean);
}
candidates.push(clean);
};

let m: RegExpExecArray | null;
const backtickRe = new RegExp(BACKTICK_SPAN.source, "g");
while ((m = backtickRe.exec(stripped)) !== null) {
const inner = m[1].trim();
if (isCodeFilePath(inner)) {
candidates.add(inner.replace(/#.*$/, ""));
addCandidate(inner);
}
}

for (const line of stripped.split("\n")) {
const strippedForBareScan = stripped.replace(BACKTICK_SPAN, (match) =>
" ".repeat(match.length),
);

for (const line of strippedForBareScan.split("\n")) {
const urlRanges: Array<[number, number]> = [];
const urlRe = new RegExp(URL_REGEX.source, "g");
while ((m = urlRe.exec(line)) !== null) {
Expand All @@ -58,9 +72,22 @@ export function extractCandidateCodePaths(markdown: string): string[] {
);
if (overlapsUrl) continue;
if (!isCodeFilePathStrict(m[0])) continue;
candidates.add(m[0].replace(/#.*$/, ""));
addCandidate(m[0]);
}
}

return Array.from(candidates);
return candidates;
}

export function extractCandidateCodePaths(markdown: string): string[] {
return collectCandidateCodePaths(markdown, true);
}

/**
* Extract candidate code-file path mentions without deduplication.
* Uses the same stripping and validation rules as `extractCandidateCodePaths`,
* but keeps repeated mentions so UI summaries can show frequency.
*/
export function extractCandidateCodePathMentions(markdown: string): string[] {
return collectCandidateCodePaths(markdown, false);
}
Loading
Loading