diff --git a/core/src/symbols/extractor.rs b/core/src/symbols/extractor.rs index d748f0f8..1cdcfeee 100644 --- a/core/src/symbols/extractor.rs +++ b/core/src/symbols/extractor.rs @@ -167,6 +167,7 @@ fn rust_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Option Option Option Option Option Option Option Option Option { kind: SymbolKind::Function, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + // The declaration itself has no body — the function + // expression on its right-hand side does. + body_start_line: body_interior_start(value, source), children: vec![], depth: None, }); @@ -367,11 +382,15 @@ fn extract_class_methods_js(class_node: Node, source: &str) -> Vec { match child.kind() { "method_definition" | "public_field_definition" => { if let Some(name) = find_child_text(child, "name", source) { + // A field holding an arrow function folds like a method, but + // its body hangs off the value rather than the field itself. + let body_owner = child.child_by_field_name("value").unwrap_or(child); methods.push(Symbol { name, kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(body_owner, source), children: vec![], depth: None, }); @@ -396,6 +415,7 @@ fn python_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -460,6 +482,8 @@ fn extract_python_methods(class_node: Node, source: &str) -> Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + // Body belongs to the def, not the decorator wrapper. + body_start_line: body_interior_start(inner, source), children: vec![], depth: None, }); @@ -486,6 +510,7 @@ fn go_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Function, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -506,6 +531,7 @@ fn go_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Method, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -527,6 +553,9 @@ fn go_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + // The declaration wraps a type_spec; any body is on the + // struct/interface type itself. + body_start_line: body_interior_start(type_node, source), children: vec![], depth: None, }); @@ -566,6 +595,7 @@ fn ruby_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -638,6 +672,7 @@ fn extract_ruby_methods(node: Node, source: &str) -> Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -680,6 +715,7 @@ fn java_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -760,6 +800,7 @@ fn c_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Function, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -771,6 +812,7 @@ fn c_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Struct, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -782,6 +824,7 @@ fn c_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Enum, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -793,6 +836,7 @@ fn c_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option kind: SymbolKind::Type, start_line: node.start_position().row as u32 + 1, end_line: node.end_position().row as u32 + 1, + body_start_line: body_interior_start(node, source), children: vec![], depth: None, }) @@ -843,6 +887,7 @@ fn cpp_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -947,6 +997,7 @@ fn extract_cpp_class_members(node: Node, source: &str) -> Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -991,6 +1042,7 @@ fn csharp_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -1111,6 +1169,7 @@ fn php_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Option Vec { kind: SymbolKind::Method, start_line: child.start_position().row as u32 + 1, end_line: child.end_position().row as u32 + 1, + body_start_line: body_interior_start(child, source), children: vec![], depth: None, }); @@ -1216,6 +1280,7 @@ fn css_node_to_symbol(node: Node, source: &str, kind_str: &str) -> Option Option Option Option Option Option Option Option { .map(|n| node_text(n, source).to_owned()) } +/// 1-based line of the first *interior* line of `node`'s body — the first line a +/// fold may hide while the full signature stays visible. +/// +/// Brace languages park the opening `{` at the end of the signature, so the +/// interior starts one line further down; indentation bodies (Python, Ruby) +/// already begin on their own line. Returns None when there is nothing to fold: +/// no body child, or a body that shares a line with the signature (one-liners, +/// expression-bodied arrow functions). +fn body_interior_start(node: Node, source: &str) -> Option { + let body = node.child_by_field_name("body")?; + let body_row = body.start_position().row as u32; + let first_interior = if node_text(body, source).starts_with('{') { + body_row + 2 + } else { + body_row + 1 + }; + let start_line = node.start_position().row as u32 + 1; + let end_line = node.end_position().row as u32 + 1; + (first_interior > start_line && first_interior <= end_line).then_some(first_interior) +} + /// Find the name for a Rust `impl` block (e.g., "MyStruct" or "MyTrait for MyStruct"). #[cfg(feature = "symbols-rust-lang")] fn find_impl_name(node: Node, source: &str) -> Option { @@ -1432,6 +1524,7 @@ fn extract_methods_from_body(parent: Node, source: &str, _ext: &str) -> Vec u32 { + a + 1 +} + +pub fn wrapped( + a: u32, +) -> u32 { + a + 1 +} + +pub fn one_liner() -> u32 { 1 } + +impl Foo { + pub fn method(&self) -> u32 { + self.x + } +} +"#; + let symbols = extract_symbols(source, "test.rs").unwrap(); + + // Signature on one line: fold starts just below the `{` line. + let foo = symbols.iter().find(|s| s.name == "foo").unwrap(); + assert_eq!(foo.body_start_line, Some(3)); + + // Multi-line signature: the `) -> u32 {` line stays visible. + let wrapped = symbols.iter().find(|s| s.name == "wrapped").unwrap(); + assert_eq!(wrapped.body_start_line, Some(9)); + + // Nothing between the braces' lines — nothing to fold. + let one_liner = symbols.iter().find(|s| s.name == "one_liner").unwrap(); + assert_eq!(one_liner.body_start_line, None); + + let impl_sym = symbols.iter().find(|s| s.kind == SymbolKind::Impl).unwrap(); + assert_eq!(impl_sym.body_start_line, Some(15)); + let method = impl_sym + .children + .iter() + .find(|s| s.name == "method") + .unwrap(); + assert_eq!(method.body_start_line, Some(16)); + } + + #[cfg(feature = "symbols-python")] + #[test] + fn test_python_body_start_line() { + let source = r#" +def foo(): + """Docstrings are interior.""" + return 1 + +class MyClass: + def method(self): + pass + +def one_liner(): return 2 +"#; + let symbols = extract_symbols(source, "test.py").unwrap(); + + let foo = symbols.iter().find(|s| s.name == "foo").unwrap(); + assert_eq!(foo.body_start_line, Some(3)); + + let class = symbols.iter().find(|s| s.name == "MyClass").unwrap(); + assert_eq!(class.body_start_line, Some(7)); + assert_eq!(class.children[0].body_start_line, Some(8)); + + let one_liner = symbols.iter().find(|s| s.name == "one_liner").unwrap(); + assert_eq!(one_liner.body_start_line, None); + } + + #[cfg(feature = "symbols-typescript")] + #[test] + fn test_typescript_body_start_line() { + let source = r#" +export function process(config: Config): void { + console.log(config.name); +} + +class Widget { + render(): string { + return "x"; + } +} + +const greet = (name: string) => { + console.log(name); +}; +"#; + let symbols = extract_symbols(source, "test.ts").unwrap(); + + let process = symbols.iter().find(|s| s.name == "process").unwrap(); + assert_eq!(process.body_start_line, Some(3)); + + let widget = symbols.iter().find(|s| s.name == "Widget").unwrap(); + assert_eq!(widget.body_start_line, Some(7)); + let render = widget.children.iter().find(|s| s.name == "render").unwrap(); + assert_eq!(render.body_start_line, Some(8)); + + // Arrow function: the body hangs off the value, not the declaration. + let greet = symbols.iter().find(|s| s.name == "greet").unwrap(); + assert_eq!(greet.body_start_line, Some(13)); + } + // --- compute_file_symbol_diff tests --- fn make_hunk( diff --git a/core/src/symbols/mod.rs b/core/src/symbols/mod.rs index da3bf8a2..b6630cb5 100644 --- a/core/src/symbols/mod.rs +++ b/core/src/symbols/mod.rs @@ -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, pub children: Vec, /// Heading depth for markdown symbols (1–6), None for code symbols. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 6c64ac3d..f245fa2b 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "review", - "version": "0.0.130", + "version": "0.0.132", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "review", - "version": "0.0.130", + "version": "0.0.132", "license": "FSL-1.1-MIT", "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", diff --git a/desktop/ui/components/FileViewer/FileCodeView.tsx b/desktop/ui/components/FileViewer/FileCodeView.tsx index 6a55f049..52f25a42 100644 --- a/desktop/ui/components/FileViewer/FileCodeView.tsx +++ b/desktop/ui/components/FileViewer/FileCodeView.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, @@ -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. */ @@ -83,6 +103,12 @@ interface FileCodeViewProps { containerRef?: (node: HTMLDivElement | null) => void; /** Imperative scroll API */ handleRef?: React.Ref; + /** + * 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; } /** @@ -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}`; @@ -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 @@ -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; @@ -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(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 ?? ""); @@ -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, @@ -294,6 +352,7 @@ export function FileCodeView({ isDiff, isDiff ? content.viewMode : null, isDiff ? content.expandUnchanged : null, + shapeMode, theme, fontCSS, extraCSS, @@ -301,6 +360,7 @@ export function FileCodeView({ diffOverflow, lineHeight, handleGutterUtilityClick, + handleShapeLineClick, diffModel.handleLineSelectionEnd, onTokenEnter, onTokenLeave, @@ -310,7 +370,9 @@ export function FileCodeView({ const selectedLines = useMemo( () => - 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: { @@ -320,7 +382,7 @@ export function FileCodeView({ }, } : null, - [highlightLine, itemId], + [highlightLine, itemId, shapeMode], ); // --- Imperative scroll API --- @@ -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(null); + const [scrollNode, setScrollNode] = useState(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 ( -
+
{!highlightReady && (
)} + {shape && ( + + )} {/* 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. */} @@ -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} @@ -397,6 +481,7 @@ export function FileCodeView({ const CODE_VIEW_STYLE = { overflow: "auto" } as const; const EMPTY_HUNKS: DiffHunk[] = []; +const EMPTY_ANNOTATIONS: PierreLineAnnotation[] = []; type PlainAnnotationLine = { lineNumber: number; endLineNumber?: number }; diff --git a/desktop/ui/components/FileViewer/FileContentRenderer.tsx b/desktop/ui/components/FileViewer/FileContentRenderer.tsx index 9594b6ff..66bceb49 100644 --- a/desktop/ui/components/FileViewer/FileContentRenderer.tsx +++ b/desktop/ui/components/FileViewer/FileContentRenderer.tsx @@ -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"; @@ -50,6 +51,12 @@ interface FileContentRendererProps { containerRef?: (node: HTMLDivElement | null) => void; /** Imperative scroll API of the rendered CodeView */ handleRef?: React.Ref; + /** + * 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({ @@ -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. @@ -94,10 +102,14 @@ export const FileContentRenderer = memo(function FileContentRenderer({ ); } - const renderCodeView = (content: FileCodeViewContent) => ( + const renderCodeView = ( + content: FileCodeViewContent, + shapeState?: ShapeViewState, + ) => ( void; +}): JSX.Element { + return ( + + + + ); +} + +type ShapeFoldState = "expanded" | "collapsed"; + +/** Expand-all / collapse-all, shown only while shape mode is on. */ +const SHAPE_FOLD_OPTIONS: [ShapeFoldState, string][] = [ + ["expanded", "Expand all"], + ["collapsed", "Collapse all"], +]; + interface FileViewerToolbarProps { filePath: string; contentMode: ContentMode; @@ -280,6 +327,18 @@ interface FileViewerToolbarProps { isWorkingTreeMode?: boolean; onExitWorkingTreeMode?: () => void; hasSymbols?: boolean; + /** + * Whether shape mode can be offered. Already encodes both halves: the + * whole-file (plain) view, and a file with foldable bodies in it — a diff + * has its own notion of what is elided. + */ + shapeAvailable: boolean; + shapeMode: boolean; + /** True when no fold is currently collapsed — disables "Expand all" */ + shapeAllExpanded: boolean; + onToggleShapeMode: () => void; + onExpandAllFolds: () => void; + onCollapseAllFolds: () => void; isExternalFile?: boolean; onCloseExternalFile?: () => void; } @@ -310,6 +369,12 @@ export const FileViewerToolbar = memo(function FileViewerToolbar({ isWorkingTreeMode, onExitWorkingTreeMode, hasSymbols, + shapeAvailable, + shapeMode, + shapeAllExpanded, + onToggleShapeMode, + onExpandAllFolds, + onCollapseAllFolds, isExternalFile, onCloseExternalFile, }: FileViewerToolbarProps) { @@ -348,6 +413,11 @@ export const FileViewerToolbar = memo(function FileViewerToolbar({ onClearHighlight(); }; + const handleShapeFoldChange = (next: ShapeFoldState) => { + if (next === "expanded") onExpandAllFolds(); + else onCollapseAllFolds(); + }; + function renderFileStatusBadge(): JSX.Element | null { if (isExternalFile) { return ( @@ -562,6 +632,28 @@ export const FileViewerToolbar = memo(function FileViewerToolbar({ onChange={handleDiffViewModeChange} /> )} + {shapeAvailable && ( + + Shape (fold bodies) + + )} + {shapeAvailable && shapeMode && ( + <> + + Expand all bodies + + + Collapse all bodies + + + )} {hasSymbols && } {onSplitOrRotate && ( @@ -577,7 +669,9 @@ export const FileViewerToolbar = memo(function FileViewerToolbar({ {isSplitActive ? "Rotate split" : "Split view"} )} - {(hasSymbols || onSplitOrRotate) && } + {(hasSymbols || onSplitOrRotate || shapeAvailable) && ( + + )} revealInBrowse(filePath)}> )} + {shapeAvailable && shapeMode && ( + + )} + {shapeAvailable && ( + + )} {hasSymbols && } {onSplitOrRotate && ( void; +} + +/** How many rows to draw beyond the viewport so a fast scroll never tears. */ +const OVERSCAN = 4; + +/** + * The real line numbers for shape mode. + * + * pierre numbers whatever document it is handed 1..N, and in shape mode that + * document is synthesized — its numbering would claim the file has no gaps, + * when the gaps *are* the elision signal. So pierre's own numbers are turned + * off (`disableLineNumbers`) and this column draws the real ones instead. + * + * It is a sibling of the scroll container, not an overlay inside it: rows are + * uniform height, so a row's y is `index * lineHeight` inside a wrapper the + * scroll offset translates, and the visible slice is a pure function of + * scrollTop and clientHeight. Only that slice is rendered, so this stays + * O(viewport) like pierre's own virtualizer — and because the offset lives on + * the wrapper's transform, a scroll frame restyles one element rather than + * re-laying out every row. + */ +export const ShapeGutter = memo(function ShapeGutter({ + rows, + lineHeight, + scrollNode, + onToggleFold, +}: ShapeGutterProps): JSX.Element { + const codeFontSize = useReviewStore((s) => s.codeFontSize); + const codeFontFamily = useReviewStore((s) => s.codeFontFamily); + + const [scroll, setScroll] = useState({ top: 0, height: 0 }); + + useEffect(() => { + if (!scrollNode) return; + let frame = 0; + const read = () => { + frame = 0; + setScroll((prev) => + prev.top === scrollNode.scrollTop && + prev.height === scrollNode.clientHeight + ? prev + : { top: scrollNode.scrollTop, height: scrollNode.clientHeight }, + ); + }; + const schedule = () => { + if (frame === 0) frame = requestAnimationFrame(read); + }; + scrollNode.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(scrollNode); + read(); + return () => { + scrollNode.removeEventListener("scroll", schedule); + observer.disconnect(); + if (frame !== 0) cancelAnimationFrame(frame); + }; + }, [scrollNode]); + + // O(1): rows are ordered, so the widest number is the last row's. + const digits = String(maxRealLine(rows)).length; + + const first = Math.max(0, Math.floor(scroll.top / lineHeight) - OVERSCAN); + const last = Math.min( + rows.length - 1, + Math.ceil((scroll.top + scroll.height) / lineHeight) + OVERSCAN, + ); + + // Rebuilt only when the visible window moves, not on every scroll frame — + // scrolling within the window just restyles the translated wrapper. + const visible = useMemo(() => { + const elements: JSX.Element[] = []; + for (let i = first; i <= last; i++) { + const row = rows[i]; + if (!row) continue; + const foldId: string | undefined = row.foldId; + + elements.push( +
+ {foldId ? ( + + ) : ( +
, + ); + } + return elements; + }, [rows, first, last, lineHeight, onToggleFold]); + + return ( +
+
+ {visible} +
+
+ ); +}); diff --git a/desktop/ui/components/FileViewer/SymbolOutlinePanel.tsx b/desktop/ui/components/FileViewer/SymbolOutlinePanel.tsx index 0aaee5b1..e42af18f 100644 --- a/desktop/ui/components/FileViewer/SymbolOutlinePanel.tsx +++ b/desktop/ui/components/FileViewer/SymbolOutlinePanel.tsx @@ -12,6 +12,12 @@ interface SymbolOutlinePanelProps { filePath: string; scrollNode: HTMLDivElement | null; symbols: FileSymbol[]; + /** + * Whether the rendered view is numbered in real file lines. False in shape + * mode, where the view is a synthesized document: both scroll tracking and + * click-to-scroll would then address the wrong lines, so they go quiet. + */ + lineAddressable?: boolean; } /** FileSymbol augmented with optional diff change type. */ @@ -50,6 +56,7 @@ export const SymbolOutlinePanel = memo(function SymbolOutlinePanel({ filePath, scrollNode, symbols: allSymbols, + lineAddressable = true, }: SymbolOutlinePanelProps) { const symbolDiffs = useReviewStore((s) => s.symbolDiffs); const toggleOutline = useReviewStore((s) => s.toggleOutline); @@ -120,6 +127,10 @@ export const SymbolOutlinePanel = memo(function SymbolOutlinePanel({ useEffect(() => { if (!scrollNode) return; + if (!lineAddressable) { + setActiveStartLine(null); + return; + } let rafId: number; const handleScroll = () => { @@ -137,7 +148,7 @@ export const SymbolOutlinePanel = memo(function SymbolOutlinePanel({ scrollNode.removeEventListener("scroll", handleScroll); cancelAnimationFrame(rafId); }; - }, [scrollNode, lineHeight]); + }, [scrollNode, lineHeight, lineAddressable]); // Auto-scroll outline list to keep active item visible useEffect(() => { @@ -152,6 +163,7 @@ export const SymbolOutlinePanel = memo(function SymbolOutlinePanel({ const handleSymbolClick = useCallback( (startLine: number) => { + if (!lineAddressable) return; useReviewStore.setState({ scrollTarget: { type: "line", @@ -160,7 +172,7 @@ export const SymbolOutlinePanel = memo(function SymbolOutlinePanel({ }, }); }, - [filePath], + [filePath, lineAddressable], ); if (outlineSymbols.length === 0) { diff --git a/desktop/ui/components/FileViewer/hooks/useShapeMode.ts b/desktop/ui/components/FileViewer/hooks/useShapeMode.ts new file mode 100644 index 00000000..1225ba31 --- /dev/null +++ b/desktop/ui/components/FileViewer/hooks/useShapeMode.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { FileSymbol } from "../../../types"; +import { stringHash } from "../../../utils/string-hash"; +import { buildShapeDocument, collectFolds } from "../shape-model"; +import type { ShapeViewState } from "../FileCodeView"; + +const NO_FOLDS_EXPANDED: ReadonlySet = new Set(); +const NO_LINES: readonly string[] = []; + +export interface ShapeModeState { + /** Whether the shape toggle can be offered for this file at all. */ + shapeAvailable: boolean; + shapeMode: boolean; + /** + * The synthesized document plus everything the view needs to render it — + * undefined whenever shape mode is off or unavailable. + */ + shape: (ShapeViewState & { content: string }) | undefined; + /** True when no fold is currently collapsed — disables "Expand all". */ + allExpanded: boolean; + toggleShapeMode: () => void; + expandAllFolds: () => void; + collapseAllFolds: () => void; +} + +/** + * Shape ("outline") reading mode for the whole-file view: every function or + * method body folded to a single `⋯` marker. See `shape-model.ts` for why the + * folded document is synthesized rather than hidden with CSS. + */ +export function useShapeMode({ + filePath, + content, + symbols, + isPlainView, +}: { + filePath: string; + /** The file's text, or undefined while it is still loading. */ + content: string | undefined; + symbols: FileSymbol[] | null; + /** Whether the file is being rendered as a whole file rather than a diff. */ + isPlainView: boolean; +}): ShapeModeState { + // Deliberately component state, like svgViewMode and markdownViewMode: a + // per-file, ephemeral reading posture that resets on every file switch and is + // never persisted. Promoting it to a store slice becomes necessary the moment + // it wants to be an APP_COMMANDS palette entry — commands run against the + // store, not against one component's local state. + const [shapeMode, setShapeMode] = useState(false); + const [expandedFolds, setExpandedFolds] = + useState>(NO_FOLDS_EXPANDED); + + useEffect(() => { + setShapeMode(false); + setExpandedFolds(NO_FOLDS_EXPANDED); + }, [filePath]); + + // Foldable bodies come straight from the symbol tree; a symbol without a + // bodyStartLine simply doesn't fold, so this degrades to "nothing to fold" + // rather than breaking while the extractor catches up. + const folds = useMemo( + () => (symbols ? collectFolds(symbols) : []), + [symbols], + ); + + // `isPlainView` (i.e. `contentMode.type === "plain"`) is half the gate on + // purpose: shape mode is only reachable for files shown without a rendered + // diff — browse mode, unchanged files, the plain view of a changed file. + // That is the intended scope of this spike; a diff already has its own + // notion of what is elided. + const shapeAvailable = isPlainView && folds.length > 0; + + const active = shapeMode && shapeAvailable && content !== undefined; + + // Split (and hash) once per file content, not once per fold toggle: only + // `expandedFolds` changes as the user folds and unfolds. + const lines = useMemo( + () => (active ? (content ?? "").split("\n") : NO_LINES), + [active, content], + ); + const contentHash = useMemo( + () => (active ? stringHash(content ?? "") : 0), + [active, content], + ); + + const shapeDocument = useMemo( + () => (active ? buildShapeDocument(lines, folds, expandedFolds) : null), + [active, lines, folds, expandedFolds], + ); + + const toggleFold = useCallback((foldId: string) => { + setExpandedFolds((prev) => { + const next = new Set(prev); + if (!next.delete(foldId)) next.add(foldId); + return next; + }); + }, []); + + const expandAllFolds = useCallback(() => { + setExpandedFolds(new Set(folds.map((f) => f.id))); + }, [folds]); + + const collapseAllFolds = useCallback(() => { + setExpandedFolds(NO_FOLDS_EXPANDED); + }, []); + + const toggleShapeMode = useCallback(() => { + setShapeMode((prev) => !prev); + setExpandedFolds(NO_FOLDS_EXPANDED); + }, []); + + const shape = useMemo(() => { + if (!shapeDocument) return undefined; + // The document is exactly (file content × which folds are open), so that + // pair is its cache key — the code view then never re-hashes the whole + // synthesized text on a toggle. + const openFolds = [...expandedFolds].sort().join(","); + return { + content: shapeDocument.content, + rows: shapeDocument.rows, + onToggleFold: toggleFold, + cacheKey: `${contentHash}:${openFolds}`, + }; + }, [shapeDocument, expandedFolds, contentHash, toggleFold]); + + return { + shapeAvailable, + shapeMode, + shape, + allExpanded: expandedFolds.size === folds.length, + toggleShapeMode, + expandAllFolds, + collapseAllFolds, + }; +} diff --git a/desktop/ui/components/FileViewer/index.tsx b/desktop/ui/components/FileViewer/index.tsx index 52f93c89..c03ea0e7 100644 --- a/desktop/ui/components/FileViewer/index.tsx +++ b/desktop/ui/components/FileViewer/index.tsx @@ -47,6 +47,7 @@ import { SymbolOutlinePanel } from "./SymbolOutlinePanel"; import { useFileSymbols } from "./useFileSymbols"; import type { ContentMode } from "./content-mode"; import { useDiffViewMode } from "./hooks/useDiffViewMode"; +import { useShapeMode } from "./hooks/useShapeMode"; const PLAIN_MODE: ContentMode = { type: "plain" }; const IMAGE_MODE: ContentMode = { type: "image" }; @@ -285,6 +286,8 @@ export function FileViewer({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!fileContentRef.current) return; + // Both bars address real file lines — suppressed in shape mode. + if (shapeModeRef.current) return; if (!(e.metaKey || e.ctrlKey) || e.shiftKey) return; if (e.key === "f") { e.preventDefault(); @@ -613,6 +616,36 @@ export function FileViewer({ return PLAIN_MODE; }, [fileContent, isGitignored, svgViewMode, viewMode]); + // --- Shape mode --------------------------------------------------------- + const { + shapeAvailable, + shapeMode, + shape, + allExpanded: shapeAllExpanded, + toggleShapeMode, + expandAllFolds, + collapseAllFolds, + } = useShapeMode({ + filePath, + content: fileContent?.content, + symbols: fileSymbols, + isPlainView: contentMode.type === "plain", + }); + + // Shape mode swaps in a second line-coordinate space (see the note at the + // search/go-to-line bars below), so entering or leaving it drops anything + // addressed in the other one. + const handleToggleShapeMode = useCallback(() => { + toggleShapeMode(); + setHighlightLine(null); + setOpenBar(null); + }, [toggleShapeMode]); + + // Read by the ⌘F / ⌘L handler, which is registered once for the viewer's + // lifetime and so can't close over the current value. + const shapeModeRef = useRef(shapeMode); + shapeModeRef.current = shapeMode; + if (loading || fileContentPath !== filePath) { return (
@@ -689,6 +722,12 @@ export function FileViewer({ isWorkingTreeMode ? handleExitWorkingTreeMode : undefined } hasSymbols={hasSymbols} + shapeAvailable={shapeAvailable} + shapeMode={shapeMode} + shapeAllExpanded={shapeAllExpanded} + onToggleShapeMode={handleToggleShapeMode} + onExpandAllFolds={expandAllFolds} + onCollapseAllFolds={collapseAllFolds} isExternalFile={isExternalFile} onCloseExternalFile={ isExternalFile @@ -742,7 +781,15 @@ export function FileViewer({ )}
- {openBar === "search" && fileContent && ( + {/* Shape mode hands pierre a *synthesized* document, so the view is + addressed in document lines while search, go-to-line and the + outline all speak real file lines. Rather than translate between + the two, this spike suppresses the line-addressed affordances while + shape mode is on: the bars don't open (see the ⌘F / ⌘L handler) and + the outline stops tracking and jumping. Closing this properly means + carrying a real↔doc mapping on `buildShapeDocument`'s rows and + translating at these three call sites. */} + {openBar === "search" && !shapeMode && fileContent && (
)} - {openBar === "goToLine" && fileContent && ( + {openBar === "goToLine" && !shapeMode && fileContent && (
)} {contentMode.type === "diff" && ( & Pick, +): FileSymbol { + return { + kind: "function", + startLine: 1, + endLine: 10, + children: [], + ...partial, + }; +} + +const EMPTY = new Set(); + +describe("collectFolds", () => { + it("folds functions and methods that declare a body start", () => { + const folds = collectFolds([ + symbol({ name: "big", startLine: 1, endLine: 20, bodyStartLine: 2 }), + ]); + expect(folds).toEqual([ + { id: "2:20", name: "big", startLine: 2, endLine: 20 }, + ]); + }); + + it("skips symbols with no bodyStartLine (the Rust half may not send one)", () => { + expect(collectFolds([symbol({ name: "unknown", endLine: 40 })])).toEqual( + [], + ); + }); + + it("skips bodies below the adaptive threshold", () => { + // 4 hidden lines (5..8) is under the 5-line minimum. + const folds = collectFolds([ + symbol({ name: "tiny", startLine: 4, endLine: 8, bodyStartLine: 5 }), + ]); + expect(folds).toEqual([]); + }); + + it("recurses into containers so methods fold individually", () => { + const folds = collectFolds([ + symbol({ + name: "Widget", + kind: "class", + startLine: 1, + endLine: 40, + bodyStartLine: 2, + children: [ + symbol({ + name: "render", + kind: "method", + startLine: 3, + endLine: 20, + bodyStartLine: 4, + }), + symbol({ + name: "update", + kind: "method", + startLine: 22, + endLine: 39, + bodyStartLine: 23, + }), + ], + }), + ]); + expect(folds.map((f) => f.name)).toEqual(["render", "update"]); + // The class body itself is never folded — that would hide the methods. + expect(folds.every((f) => f.startLine > 2)).toBe(true); + }); + + it("keeps only the outermost fold when functions nest", () => { + const folds = collectFolds([ + symbol({ + name: "outer", + startLine: 1, + endLine: 30, + bodyStartLine: 2, + children: [ + symbol({ + name: "closure", + startLine: 5, + endLine: 15, + bodyStartLine: 6, + }), + ], + }), + ]); + expect(folds.map((f) => f.name)).toEqual(["outer"]); + }); + + it("still folds a nested function when its parent was too small to fold", () => { + const folds = collectFolds([ + symbol({ + name: "thin", + startLine: 1, + endLine: 3, + bodyStartLine: 2, + children: [ + symbol({ + name: "inner", + startLine: 5, + endLine: 15, + bodyStartLine: 6, + }), + ], + }), + ]); + expect(folds.map((f) => f.name)).toEqual(["inner"]); + }); + + it("drops folds that overlap an earlier one", () => { + const folds = collectFolds([ + symbol({ name: "a", startLine: 1, endLine: 20, bodyStartLine: 2 }), + symbol({ name: "b", startLine: 10, endLine: 30, bodyStartLine: 11 }), + ]); + expect(folds.map((f) => f.name)).toEqual(["a"]); + }); +}); + +const FILE_LINES = [ + "import os", // 1 + "", // 2 + "", // 3 + "def greet(name):", // 4 + ' """Say hello."""', // 5 + " a = 1", // 6 + " b = 2", // 7 + " c = 3", // 8 + " return a + b + c", // 9 + "", // 10 + "", // 11 + "VALUE = 3", // 12 +]; +const FILE = FILE_LINES.join("\n"); + +const GREET = { id: "5:9", name: "greet", startLine: 5, endLine: 9 }; + +describe("buildShapeDocument", () => { + it("replaces a collapsed body with one indent-matched marker line", () => { + const doc = buildShapeDocument(FILE_LINES, [GREET], EMPTY); + expect(doc.content.split("\n")).toEqual([ + "import os", + "", + "", + "def greet(name):", + ` ${SHAPE_MARKER}`, + "", + "", + "VALUE = 3", + ]); + const markers = doc.rows.filter((r) => r.kind === "marker"); + expect(markers).toHaveLength(1); + expect(markers[0]).toMatchObject({ hiddenLines: 5 }); + }); + + it("maps every rendered row back to its real line number", () => { + const doc = buildShapeDocument(FILE_LINES, [GREET], EMPTY); + expect( + doc.rows.map((r) => (r.kind === "code" ? r.line : "marker")), + ).toEqual([1, 2, 3, 4, "marker", 10, 11, 12]); + // The gap in the numbering is the elision signal: the hidden lines have no + // row at all, and the lines after them keep their real numbers. + expect(doc.rows[7]).toEqual({ kind: "code", line: 12 }); + expect(doc.rows.some((r) => r.kind === "code" && r.line === 6)).toBe(false); + expect(maxRealLine(doc.rows)).toBe(12); + }); + + it("carries the hidden range on the marker row", () => { + const doc = buildShapeDocument(FILE_LINES, [GREET], EMPTY); + // `rows[n - 1]` describes doc line `n` — the marker is on doc line 5. + expect(doc.rows[4]).toEqual({ + kind: "marker", + foldId: "5:9", + foldName: "greet", + startLine: 5, + endLine: 9, + hiddenLines: 5, + }); + }); + + it("restores the literal file when a fold is expanded", () => { + const doc = buildShapeDocument(FILE_LINES, [GREET], new Set(["5:9"])); + expect(doc.content).toBe(FILE); + expect(doc.rows.some((r) => r.kind === "marker")).toBe(false); + }); + + /** + * The whole reason expanding doesn't move the page: the marker occupies the + * same row index that the body's first line takes once expanded. + */ + it("puts the expanded body's first line at the marker's row index", () => { + const collapsed = buildShapeDocument(FILE_LINES, [GREET], EMPTY); + const expanded = buildShapeDocument(FILE_LINES, [GREET], new Set(["5:9"])); + const markerIndex = collapsed.rows.findIndex((r) => r.kind === "marker"); + expect(expanded.rows[markerIndex]).toEqual({ + kind: "code", + line: 5, + foldId: "5:9", + foldName: "greet", + }); + }); + + it("is a no-op when nothing folds", () => { + const doc = buildShapeDocument(FILE_LINES, [], EMPTY); + expect(doc.content).toBe(FILE); + expect(doc.rows).toHaveLength(12); + }); + + it("preserves a trailing newline", () => { + const doc = buildShapeDocument(["a", "b", ""], [], EMPTY); + expect(doc.content).toBe("a\nb\n"); + expect(doc.rows).toHaveLength(2); + }); + + it("clamps a fold that runs past the end of the file", () => { + const doc = buildShapeDocument( + FILE_LINES, + [{ ...GREET, endLine: 999 }], + EMPTY, + ); + const last = doc.rows[doc.rows.length - 1]; + expect(last).toMatchObject({ kind: "marker", startLine: 5, endLine: 12 }); + expect(doc.content.endsWith(` ${SHAPE_MARKER}`)).toBe(true); + expect(maxRealLine(doc.rows)).toBe(12); + }); + + it("indents the marker from the first non-blank hidden line", () => { + const nested = ["class A:", " def m(self):", "", "", "", "", ""]; + const doc = buildShapeDocument( + nested, + [{ id: "3:7", name: "m", startLine: 3, endLine: 7 }], + EMPTY, + ); + // Body is entirely blank — fall back to the signature's indent plus a step. + expect(doc.content.split("\n")[2]).toBe(` ${SHAPE_MARKER}`); + }); +}); diff --git a/desktop/ui/components/FileViewer/shape-model.ts b/desktop/ui/components/FileViewer/shape-model.ts new file mode 100644 index 00000000..d310048f --- /dev/null +++ b/desktop/ui/components/FileViewer/shape-model.ts @@ -0,0 +1,220 @@ +import type { FileSymbol } from "../../types"; + +/** + * "Shape" reading mode: fold every function/method body down to a single + * elision marker so a file reads as its outline. + * + * The whole-file view renders through pierre's CodeView, which has no + * fold/hidden-range API and recycles row elements while virtualizing — hiding + * rows with CSS would corrupt both the scroll height and the element pool. So + * instead of hiding rows we hand pierre a *smaller real document*: the file + * with every collapsed body removed and one indent-matched marker line put in + * its place. Virtualization stays correct because the document is real. + * + * The cost is that pierre then numbers that document 1..N, which is wrong. + * Line numbers are therefore disabled in pierre (`disableLineNumbers`) and a + * custom gutter renders the real numbers from `ShapeDocument.rows`. + */ + +/** The elision marker. Chosen because highlighters leave it as plain text. */ +export const SHAPE_MARKER = "⋯"; + +/** Bodies shorter than this aren't worth folding — the marker costs a line. */ +const MIN_HIDDEN_LINES = 5; + +/** A foldable function/method body, in real (1-based) file line numbers. */ +export interface ShapeFold { + /** Stable within a file+symbol set — safe to key expanded state by. */ + id: string; + /** Symbol name, for tooltips/aria. */ + name: string; + /** First line hidden by this fold (the signature stays visible). */ + startLine: number; + /** Last line hidden by this fold (inclusive; usually the closing brace). */ + endLine: number; +} + +/** One rendered row of the synthesized document. */ +export type ShapeRow = + | { + kind: "code"; + /** Real (1-based) line number in the original file. */ + line: number; + /** + * Set when this row is the first body line of an *expanded* fold — the + * gutter hangs the collapse affordance here, which is also the row the + * marker occupied before expanding (so toggling never moves content). + */ + foldId?: string; + foldName?: string; + } + | { + kind: "marker"; + foldId: string; + foldName: string; + /** First real line hidden behind this marker. */ + startLine: number; + /** Last real line hidden behind this marker. */ + endLine: number; + /** How many real lines the marker stands for. */ + hiddenLines: number; + }; + +export interface ShapeDocument { + /** The synthesized file text handed to pierre. */ + content: string; + /** One entry per line of `content`; `rows[n - 1]` describes doc line `n`. */ + rows: ShapeRow[]; +} + +const FOLDABLE_KINDS = new Set(["function", "method"]); + +/** + * Collects the function/method bodies worth folding. + * + * Recurses through containers (class/impl/module) so their methods fold + * individually — folding a container would hide its members' signatures, which + * is exactly what shape mode exists to show. Once a body is folded, nested + * definitions inside it are skipped: only the outermost function-level fold + * survives. + * + * `bodyStartLine` is optional on the wire (the Rust side may not supply it + * yet); a symbol without one simply doesn't fold. + */ +export function collectFolds(symbols: readonly FileSymbol[]): ShapeFold[] { + const collected: ShapeFold[] = []; + + const walk = (nodes: readonly FileSymbol[]): void => { + for (const symbol of nodes) { + const folded = + FOLDABLE_KINDS.has(symbol.kind) && + typeof symbol.bodyStartLine === "number" && + symbol.bodyStartLine >= 1 && + symbol.endLine >= symbol.bodyStartLine && + symbol.endLine - symbol.bodyStartLine + 1 >= MIN_HIDDEN_LINES; + + if (folded) { + const startLine = symbol.bodyStartLine!; + collected.push({ + id: `${startLine}:${symbol.endLine}`, + name: symbol.name, + startLine, + endLine: symbol.endLine, + }); + // Nested defs inside a folded body are invisible anyway. + continue; + } + + if (symbol.children.length > 0) walk(symbol.children); + } + }; + + walk(symbols); + + // Tree-sitter output is not guaranteed to be ordered or strictly nested; + // overlapping folds would produce an incoherent document, so keep the first + // (outermost) of any overlapping pair. + collected.sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine); + const result: ShapeFold[] = []; + let lastEnd = 0; + for (const fold of collected) { + if (fold.startLine <= lastEnd) continue; + result.push(fold); + lastEnd = fold.endLine; + } + return result; +} + +/** + * Builds the document pierre actually renders: the file with every collapsed + * fold replaced by one indent-matched marker line. + * + * Row indices are the load-bearing part. A collapsed fold's marker sits at the + * same row index its first body line takes when expanded, so toggling a single + * fold never shifts anything above it and the scroll position stays put. + */ +export function buildShapeDocument( + lines: readonly string[], + folds: readonly ShapeFold[], + expandedFoldIds: ReadonlySet, +): ShapeDocument { + // A trailing newline yields a phantom empty element; ignore it and restore + // it on join so the synthesized document keeps the file's line count. + const hasTrailingNewline = lines.length > 1 && lines[lines.length - 1] === ""; + const lineCount = hasTrailingNewline ? lines.length - 1 : lines.length; + + const foldByStart = new Map(); + for (const fold of folds) { + if (fold.startLine < 1 || fold.startLine > lineCount) continue; + foldByStart.set(fold.startLine, fold); + } + + const outLines: string[] = []; + const rows: ShapeRow[] = []; + + let line = 1; + while (line <= lineCount) { + const fold = foldByStart.get(line); + if (fold && !expandedFoldIds.has(fold.id)) { + const endLine = Math.min(fold.endLine, lineCount); + outLines.push(markerLineFor(lines, line, endLine)); + rows.push({ + kind: "marker", + foldId: fold.id, + foldName: fold.name, + startLine: line, + endLine, + hiddenLines: endLine - line + 1, + }); + line = endLine + 1; + continue; + } + + outLines.push(lines[line - 1]); + rows.push( + fold + ? { kind: "code", line, foldId: fold.id, foldName: fold.name } + : { kind: "code", line }, + ); + line += 1; + } + + const content = + outLines.join("\n") + + (hasTrailingNewline && outLines.length > 0 ? "\n" : ""); + + return { content, rows }; +} + +/** Indent the marker to match the body it stands for. */ +function markerLineFor( + lines: readonly string[], + startLine: number, + endLine: number, +): string { + for (let i = startLine; i <= endLine; i++) { + const text = lines[i - 1]; + if (text == null || text.trim() === "") continue; + return leadingWhitespace(text) + SHAPE_MARKER; + } + // Whole body is blank — fall back to the signature's indent plus a step. + const signature = lines[startLine - 2]; + return (signature ? leadingWhitespace(signature) : "") + " " + SHAPE_MARKER; +} + +function leadingWhitespace(text: string): string { + const match = /^[ \t]*/.exec(text); + return match ? match[0] : ""; +} + +/** + * Widest real line number in the document — sizes the custom gutter. + * + * Row order is monotonic in real line numbers, so the last row carries the + * largest one: its own line for code, its hidden range's end for a marker. + */ +export function maxRealLine(rows: readonly ShapeRow[]): number { + const last = rows[rows.length - 1]; + if (!last) return 0; + return last.kind === "code" ? last.line : last.endLine; +} diff --git a/desktop/ui/types/index.ts b/desktop/ui/types/index.ts index 2c912827..eb5898a5 100644 --- a/desktop/ui/types/index.ts +++ b/desktop/ui/types/index.ts @@ -427,7 +427,11 @@ export function isHunkTrusted( // the CLI's EffectiveStatus mirrors and every status consumer should route // through. export type EffectiveStatusValue = - "unreviewed" | "trusted" | "approved" | "rejected" | "saved"; + | "unreviewed" + | "trusted" + | "approved" + | "rejected" + | "saved"; export function effectiveHunkStatus( hunkState: HunkState | undefined, @@ -631,6 +635,13 @@ export interface FileSymbol { endLine: number; children: FileSymbol[]; depth?: number; + /** + * 1-based line where the symbol's *body* starts — the first line a fold may + * hide, so the signature (however many lines it spans, including its + * trailing `{`) stays visible. Optional: extractors that don't report one + * simply produce a symbol that shape mode won't fold. + */ + bodyStartLine?: number; } export interface RepoFileSymbols { @@ -828,7 +839,10 @@ export interface LspServerStatus { * when shell integration is active, foreground-process polling otherwise). */ export type TerminalPhase = - "working" | "waiting_for_input" | "needs_attention" | "idle"; + | "working" + | "waiting_for_input" + | "needs_attention" + | "idle"; /** * Status snapshot for a single terminal session. Mirrors the backend