Skip to content
Draft
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
203 changes: 203 additions & 0 deletions core/src/symbols/extractor.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions core/src/symbols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ pub struct Symbol {
pub start_line: u32,
#[serde(rename = "endLine")]
pub end_line: u32,
/// First interior line of the body (1-based). Everything before it is the
/// signature, including a trailing brace on its own line; `end_line` is the
/// last line of the body. None when the body isn't identifiable, or when
/// nothing lies between the signature and the end of the symbol.
#[serde(rename = "bodyStartLine", skip_serializing_if = "Option::is_none")]
pub body_start_line: Option<u32>,
pub children: Vec<Symbol>,
/// Heading depth for markdown symbols (1–6), None for code symbols.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 98 additions & 13 deletions desktop/ui/components/FileViewer/FileCodeView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
Expand Down Expand Up @@ -41,6 +42,25 @@ import {
type TokenHoverHandler,
type TokenClickHandler,
} from "./diff-model";
import { ShapeGutter } from "./ShapeGutter";
import type { ShapeRow } from "./shape-model";

/**
* Shape ("outline") reading posture for a plain file: the content handed in is
* a synthesized document with folded bodies elided, and `rows` says what each
* of its lines really is. See `shape-model.ts` for why the document is
* synthesized rather than hidden with CSS.
*/
export interface ShapeViewState {
rows: ShapeRow[];
onToggleFold: (foldId: string) => void;
/**
* Identifies the synthesized document (file content + which folds are open),
* so pierre's cache key doesn't have to re-hash the whole document on every
* fold toggle. Supplied by the shape-mode hook that builds `rows`.
*/
cacheKey: string;
}

export interface FileCodeViewHandle {
/** Scroll a line into view — CodeView computes the exact offset, no polling. */
Expand Down Expand Up @@ -83,6 +103,12 @@ interface FileCodeViewProps {
containerRef?: (node: HTMLDivElement | null) => void;
/** Imperative scroll API */
handleRef?: React.Ref<FileCodeViewHandle>;
/**
* Present only for a plain file being read in shape mode. Turns the surface
* read-only (no comment gutter, no token hover/click) and swaps pierre's
* line numbers for the real ones.
*/
shape?: ShapeViewState;
}

/**
Expand All @@ -105,10 +131,13 @@ export function FileCodeView({
onTokenClick,
containerRef,
handleRef,
shape,
}: FileCodeViewProps): ReactNode {
const diffOverflow = useReviewStore((s) => s.diffOverflow);

const isDiff = content.kind === "diff";
// Only ever set for a plain file — a diff has its own notion of elision.
const shapeMode = shape !== undefined;
const hunks = isDiff ? content.hunks : EMPTY_HUNKS;
const itemId = isDiff ? `diff:${filePath}` : `file:${filePath}`;

Expand Down Expand Up @@ -163,6 +192,13 @@ export function FileCodeView({
}, [isDiff, filePath, oldContentHash, newContentHash, diffPatch, language]);

const plainContent = !isDiff ? content.content : "";
// In shape mode the document is re-synthesized on every fold toggle, and its
// cacheKey already identifies (file content × open folds) — so take that
// instead of re-hashing the whole synthesized text per toggle.
const plainCacheKey = useMemo(
() => shape?.cacheKey ?? stringHash(plainContent),
[shape, plainContent],
);
const plainFile = useMemo(
() =>
isDiff
Expand All @@ -171,17 +207,22 @@ export function FileCodeView({
name: filePath,
contents: plainContent,
lang: language,
cacheKey: `file:${filePath}:${stringHash(plainContent)}`,
cacheKey: `file:${filePath}:${plainCacheKey}`,
},
[isDiff, filePath, plainContent, language],
[isDiff, filePath, plainContent, language, plainCacheKey],
);

// Controlled items: CodeView only re-reads an item (and re-invokes its
// annotation renderers) when its version changes, so bump it whenever the
// payload, the annotations, or any state the renderers read changes.
const annotations = isDiff
? diffModel.lineAnnotations
: plainModel.lineAnnotations;
// Shape mode is a reading posture, not an editing surface: comments and
// their editors stay out of the synthesized document, whose line numbers
// wouldn't line up with the real file anyway.
const annotations = shapeMode
? EMPTY_ANNOTATIONS
: isDiff
? diffModel.lineAnnotations
: plainModel.lineAnnotations;
const renderRevision = isDiff
? diffModel.renderRevision
: plainModel.renderRevision;
Expand Down Expand Up @@ -253,6 +294,18 @@ export function FileCodeView({
newContent,
);

// --- Shape mode: clicking an elision marker expands that body ---
// pierre reports the line number of the *synthesized* document, which is
// exactly the index into `shape.rows`.
const shapeRef = useRef<ShapeViewState | undefined>(undefined);
shapeRef.current = shape;
const handleShapeLineClick = useCallback((props: { lineNumber: number }) => {
const state = shapeRef.current;
if (!state) return;
const row = state.rows[props.lineNumber - 1];
if (row?.kind === "marker") state.onToggleFold(row.foldId);
}, []);

const extraCSS = isDiff
? diffModel.annotationHighlightCSS
: (content.extraCSS ?? "");
Expand All @@ -267,13 +320,18 @@ export function FileCodeView({
// FileViewerToolbar already shows the filename and review actions —
// suppress pierre's default per-file header to avoid duplication.
disableFileHeader: true,
enableGutterUtility: true,
// Shape mode reads a synthesized document: pierre's 1..N numbering would
// be wrong, so it is switched off and ShapeGutter draws the real numbers.
disableLineNumbers: shapeMode,
enableGutterUtility: !shapeMode,
enableLineSelection: isDiff,
onGutterUtilityClick: handleGutterUtilityClick,
onLineSelectionEnd: diffModel.handleLineSelectionEnd,
onTokenEnter,
onTokenLeave,
onTokenClick,
onLineClick: shapeMode ? handleShapeLineClick : undefined,
lineHoverHighlight: shapeMode ? "line" : undefined,
onTokenEnter: shapeMode ? undefined : onTokenEnter,
onTokenLeave: shapeMode ? undefined : onTokenLeave,
onTokenClick: shapeMode ? undefined : onTokenClick,
unsafeCSS: fontCSS + extraCSS,
expandUnchanged: isDiff ? content.expandUnchanged : true,
expansionLineCount: 20,
Expand All @@ -294,13 +352,15 @@ export function FileCodeView({
isDiff,
isDiff ? content.viewMode : null,
isDiff ? content.expandUnchanged : null,
shapeMode,
theme,
fontCSS,
extraCSS,
lineDiffType,
diffOverflow,
lineHeight,
handleGutterUtilityClick,
handleShapeLineClick,
diffModel.handleLineSelectionEnd,
onTokenEnter,
onTokenLeave,
Expand All @@ -310,7 +370,9 @@ export function FileCodeView({

const selectedLines = useMemo<CodeViewLineSelection | null>(
() =>
highlightLine
// Line selection is off in shape mode, and `highlightLine` is a real
// line number that the synthesized document does not share.
highlightLine && !shapeMode
? {
id: itemId,
range: {
Expand All @@ -320,7 +382,7 @@ export function FileCodeView({
},
}
: null,
[highlightLine, itemId],
[highlightLine, itemId, shapeMode],
);

// --- Imperative scroll API ---
Expand Down Expand Up @@ -349,21 +411,43 @@ export function FileCodeView({
: (plainFile?.cacheKey ?? filePath);
const highlightReady = useSyntaxHighlightReady(shimmerRef, contentKey);

// ShapeGutter has to follow pierre's scroll offset, so the container is kept
// as state (not just a ref) for the one render that hands it over. Only shape
// mode needs it, so every other file view is spared that extra render.
// pierre's own container ref is identity-stable and fires only when the node
// mounts, so switching into shape mode later publishes the captured node.
const scrollNodeRef = useRef<HTMLDivElement | null>(null);
const [scrollNode, setScrollNode] = useState<HTMLDivElement | null>(null);

const setContainerNode = useCallback(
(node: HTMLDivElement | null) => {
shimmerRef.current = node;
scrollNodeRef.current = node;
setScrollNode(shapeRef.current ? node : null);
containerRef?.(node);
},
[containerRef],
);

useEffect(() => {
setScrollNode(shapeMode ? scrollNodeRef.current : null);
}, [shapeMode]);

return (
<div className="relative min-w-0 flex-1 h-full diff-container">
<div className="relative flex min-w-0 flex-1 h-full diff-container">
{!highlightReady && (
<div className="absolute top-0 left-0 right-0 z-10 h-0.5 overflow-hidden">
<div className="h-full w-1/3 animate-[shimmer_1s_ease-in-out_infinite] bg-status-renamed/50 rounded-full" />
</div>
)}
{shape && (
<ShapeGutter
rows={shape.rows}
lineHeight={lineHeight}
scrollNode={scrollNode}
onToggleFold={shape.onToggleFold}
/>
)}
{/* Keyed per file only (parity with the old key={fileName}) — content
changes flow through the versioned item so CodeView updates in
place and preserves the scroll anchor instead of remounting. */}
Expand All @@ -385,7 +469,7 @@ export function FileCodeView({
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
containerRef={setContainerNode}
className={`h-full w-full bg-surface-panel ${
className={`h-full min-w-0 flex-1 bg-surface-panel ${
isDiff ? "scrollbar-none" : "scrollbar-thin"
}`}
style={CODE_VIEW_STYLE}
Expand All @@ -397,6 +481,7 @@ export function FileCodeView({

const CODE_VIEW_STYLE = { overflow: "auto" } as const;
const EMPTY_HUNKS: DiffHunk[] = [];
const EMPTY_ANNOTATIONS: PierreLineAnnotation<AnnotationMeta>[] = [];

type PlainAnnotationLine = { lineNumber: number; endLineNumber?: number };

Expand Down
21 changes: 19 additions & 2 deletions desktop/ui/components/FileViewer/FileContentRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FileCodeView,
type FileCodeViewHandle,
type FileCodeViewContent,
type ShapeViewState,
} from "./FileCodeView";
import type { TokenHoverHandler, TokenClickHandler } from "./diff-model";
import type { ContentMode } from "./content-mode";
Expand Down Expand Up @@ -50,6 +51,12 @@ interface FileContentRendererProps {
containerRef?: (node: HTMLDivElement | null) => void;
/** Imperative scroll API of the rendered CodeView */
handleRef?: React.Ref<FileCodeViewHandle>;
/**
* Shape ("outline") reading mode for the whole-file view. Supplies the
* synthesized, body-folded document that replaces `fileContent.content`.
* Only ever set for `contentMode.type === "plain"`.
*/
shape?: ShapeViewState & { content: string };
}

export const FileContentRenderer = memo(function FileContentRenderer({
Expand All @@ -69,6 +76,7 @@ export const FileContentRenderer = memo(function FileContentRenderer({
onTokenClick,
containerRef,
handleRef,
shape,
}: FileContentRendererProps) {
// Tracked by path rather than as a boolean so switching files drops back to
// the automatic decision without an effect.
Expand All @@ -94,10 +102,14 @@ export const FileContentRenderer = memo(function FileContentRenderer({
);
}

const renderCodeView = (content: FileCodeViewContent) => (
const renderCodeView = (
content: FileCodeViewContent,
shapeState?: ShapeViewState,
) => (
<FileCodeView
filePath={filePath}
content={content}
shape={shapeState}
theme={codeTheme}
fontCSS={fontCSS}
language={effectiveLanguage}
Expand Down Expand Up @@ -204,7 +216,12 @@ export const FileContentRenderer = memo(function FileContentRenderer({

case "svg":
case "plain":
// Plain code view (file view mode, or files without changes)
// Plain code view (file view mode, or files without changes). In shape
// mode pierre is handed the synthesized, body-folded document instead of
// the literal file — see shape-model.ts.
if (shape) {
return renderCodeView({ kind: "plain", content: shape.content }, shape);
}
return renderCodeView({ kind: "plain", content: fileContent.content });
}
});
Expand Down
Loading