diff --git a/backlog/tasks/back-467 - Add-local-file-preview-with-syntax-highlighting-and-line-numbers.md b/backlog/tasks/back-467 - Add-local-file-preview-with-syntax-highlighting-and-line-numbers.md new file mode 100644 index 000000000..9b58caa40 --- /dev/null +++ b/backlog/tasks/back-467 - Add-local-file-preview-with-syntax-highlighting-and-line-numbers.md @@ -0,0 +1,88 @@ +--- +id: BACK-467 +title: Add local file preview with syntax highlighting and line numbers +status: Done +assignee: kuwork +created_date: '2026-04-26' +updated_date: '2026-05-07 09:25' +labels: + - enhancement + - web-ui +dependencies: [] +references: + - src/server/index.ts + - src/web/components/FilePreviewModal.tsx + - src/web/components/MermaidMarkdown.tsx + - src/web/components/TaskDetailsModal.tsx:707-715 + - src/web/lib/api.ts + - src/web/styles/style.css + +priority: medium +--- + +## Description + +Add a local file preview feature to the Web UI that allows users to click on local file paths in task References, Documentation, and Markdown content (Description, Plan, Notes, Final Summary) to view file contents directly in a modal. + +**Path semantics:** Paths are always resolved relative to the project root (the directory containing `backlog/`). Users should write paths as relative paths from the project root — for example, `src/server/index.ts` or `CLI-INSTRUCTIONS.md`. Absolute paths are not supported, and attempts to traverse above the project root (`../`) are rejected by the API for security. + +The preview supports: +- Full file viewing for code and markdown files +- Syntax highlighting via MDEditor.Markdown with Prism +- Line numbers rendered via CSS counters +- Partial line ranges (e.g., `src/server/index.ts:35-39`) with correct offset numbering +- Language detection from file extension for syntax highlighting +- Fallback to normal link behavior when a file does not exist + +## Acceptance Criteria + +- [x] #1 Backend /api/file-content endpoint reads local files within project root +- [x] #2 API supports optional line ranges (e.g., file.ts:35-39) with security checks +- [x] #3 API prevents directory traversal outside project root +- [x] #4 FilePreviewModal component renders code files with syntax highlighting +- [x] #5 FilePreviewModal displays line numbers using CSS counters +- [x] #6 Partial line ranges show correct starting line numbers +- [x] #7 MermaidMarkdown intercepts local file links to open preview modal +- [x] #8 References and Documentation local paths are clickable in TaskDetailsModal +- [x] #9 Non-existent files fall back to normal browser link behavior + + +## Definition of Done + +- [x] All acceptance criteria implemented and verified +- [x] Code reviewed and feedback addressed +- [x] No unrelated changes to existing markdown/link behavior +- [x] Backlog task metadata accurate (correct task ID, assignee, and AC) + +## Implementation Plan + + +## Implementation Plan + +### Backend +1. Add `/api/file-content` route to `src/server/index.ts` +2. Parse optional line range from path parameter (`file:lineStart-lineEnd`) +3. Resolve path within project root and reject directory traversal +4. Return file content, path, line range, total lines, and markdown flag + +### Frontend API +1. Add `fetchFileContent(path)` to `src/web/lib/api.ts` + +### File Preview Modal +1. Create `src/web/components/FilePreviewModal.tsx` +2. Detect language from file extension for fenced code blocks +3. Render markdown files with `MermaidMarkdown` +4. Render code files via `MDEditor.Markdown` wrapped in fenced blocks +5. Add CSS counter-based line numbers with `counterReset` for partial ranges + +### Markdown Link Interception +1. Add `onFileClick` prop to `MermaidMarkdown` +2. Custom `a` component for `MDEditor.Markdown` +3. `isExternalLink()` to distinguish URLs from local paths +4. Async click handler: verify file exists via API, then preview or fallback + +### Task Details Modal +1. Pass `onFileClick` to all `MermaidMarkdown` instances (Description, Plan, Notes, Final Summary) +2. Render local file paths in References and Documentation as clickable buttons +3. Open `FilePreviewModal` on click + diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts index 160d075de..ac4c759d1 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1,5 +1,5 @@ -import { mkdir, rename, unlink } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { mkdir, rename, stat, unlink } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import matter from "gray-matter"; import lockfile from "proper-lockfile"; import { DEFAULT_DIRECTORIES, DEFAULT_FILES, DEFAULT_STATUSES, FALLBACK_STATUS } from "../constants/index.ts"; @@ -1605,6 +1605,76 @@ ${description || `Milestone: ${title}`}`, return changed ? escaped : undefined; } + async readProjectFile(rawPath: string): Promise<{ + content: string; + path: string; + lineStart?: number; + lineEnd?: number; + totalLines: number; + isMarkdown: boolean; + }> { + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + + const lineRangeMatch = rawPath.match(/^(.+?)(?::(\d+)(?:-(\d+))?)?$/); + const filePath = lineRangeMatch?.[1] ?? rawPath; + const lineStart = lineRangeMatch?.[2] ? Number.parseInt(lineRangeMatch[2], 10) : undefined; + const lineEnd = lineRangeMatch?.[3] ? Number.parseInt(lineRangeMatch[3], 10) : lineStart; + + const rootDir = resolve(this.projectRoot); + const targetPath = resolve(join(rootDir, filePath)); + + // Robust containment check: reject traversal, absolute paths, and root itself + const rel = relative(rootDir, targetPath); + const isInside = !rel.startsWith("..") && !isAbsolute(rel); + if (!isInside || isAbsolute(filePath)) { + throw new Error("Access denied"); + } + + let fileStats: ReturnType extends Promise ? T : never; + try { + fileStats = await stat(targetPath); + } catch { + throw new Error("File not found"); + } + if (fileStats.isDirectory()) { + throw new Error("Path is a directory"); + } + if (fileStats.size > MAX_FILE_SIZE) { + throw new Error("File too large"); + } + + const file = Bun.file(targetPath); + const fullContent = await file.text(); + const allLines = fullContent.split(/\r?\n/); + const totalLines = allLines.length; + + let content: string; + let start: number | undefined; + let end: number | undefined; + + if (lineStart !== undefined && lineEnd !== undefined) { + start = Math.max(1, lineStart); + end = Math.min(totalLines, lineEnd); + if (start > end) { + throw new Error("Invalid line range"); + } + content = allLines.slice(start - 1, end).join("\n"); + } else { + content = fullContent; + } + + const isMarkdown = targetPath.toLowerCase().endsWith(".md"); + + return { + content, + path: filePath, + lineStart: start, + lineEnd: end, + totalLines, + isMarkdown, + }; + } + private normalizeDefinitionOfDone(definitionOfDone: unknown): string[] | undefined { if (!Array.isArray(definitionOfDone)) { return undefined; diff --git a/src/server/index.ts b/src/server/index.ts index bb13bb4f6..83fc3e164 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -405,6 +405,9 @@ export class BacklogServer { "/api/search": { GET: async (req: Request) => await this.handleSearch(req), }, + "/api/file-content": { + GET: async (req: Request) => await this.handleGetFileContent(req), + }, "/sequences": { GET: async () => await this.handleGetSequences(), }, @@ -1673,6 +1676,32 @@ export class BacklogServer { } } + private async handleGetFileContent(req: Request): Promise { + try { + const url = new URL(req.url); + const rawPath = url.searchParams.get("path") || ""; + if (!rawPath) { + return Response.json({ error: "path parameter is required" }, { status: 400 }); + } + + const result = await this.core.filesystem.readProjectFile(rawPath); + return Response.json(result); + } catch (error) { + console.error("Error reading file:", error); + const message = error instanceof Error ? error.message : "Failed to read file"; + if (message === "Access denied") { + return Response.json({ error: message }, { status: 403 }); + } + if (message === "File not found" || message === "Path is a directory") { + return Response.json({ error: message }, { status: 404 }); + } + if (message === "Invalid line range" || message === "File too large") { + return Response.json({ error: message }, { status: 400 }); + } + return Response.json({ error: message }, { status: 500 }); + } + } + private async handleGetStatus(): Promise { try { const config = await this.core.filesystem.loadConfig(); diff --git a/src/test/filesystem.test.ts b/src/test/filesystem.test.ts index 34e6f1d50..9155e14ba 100644 --- a/src/test/filesystem.test.ts +++ b/src/test/filesystem.test.ts @@ -907,4 +907,54 @@ Invalid content`, expect(filename?.includes("--")).toBe(false); }); }); + + describe("readProjectFile", () => { + it("reads a valid file within project root", async () => { + const testFile = join(TEST_DIR, "src", "test.ts"); + await mkdir(join(TEST_DIR, "src"), { recursive: true }); + await Bun.write(testFile, "line 1\nline 2\nline 3"); + const result = await filesystem.readProjectFile("src/test.ts"); + expect(result.content).toBe("line 1\nline 2\nline 3"); + expect(result.totalLines).toBe(3); + expect(result.isMarkdown).toBe(false); + }); + + it("rejects sibling-prefix traversal", async () => { + await mkdir(join(TEST_DIR, "project-secret"), { recursive: true }); + await Bun.write(join(TEST_DIR, "project-secret", "secret.txt"), "secret"); + await expect(filesystem.readProjectFile("../project-secret/secret.txt")).rejects.toThrow("Access denied"); + }); + + it("rejects absolute paths", async () => { + await expect(filesystem.readProjectFile("/etc/passwd")).rejects.toThrow("Access denied"); + }); + + it("rejects .. traversal", async () => { + await expect(filesystem.readProjectFile("../../../etc/passwd")).rejects.toThrow("Access denied"); + }); + + it("supports line ranges", async () => { + const testFile = join(TEST_DIR, "file.ts"); + await Bun.write(testFile, "a\nb\nc\nd\ne\n"); + const result = await filesystem.readProjectFile("file.ts:2-4"); + expect(result.content).toBe("b\nc\nd"); + expect(result.lineStart).toBe(2); + expect(result.lineEnd).toBe(4); + }); + + it("rejects non-existent files", async () => { + await expect(filesystem.readProjectFile("missing.txt")).rejects.toThrow("File not found"); + }); + + it("rejects directories", async () => { + await mkdir(join(TEST_DIR, "subdir"), { recursive: true }); + await expect(filesystem.readProjectFile("subdir")).rejects.toThrow("Path is a directory"); + }); + + it("rejects oversized files", async () => { + const bigFile = join(TEST_DIR, "big.txt"); + await Bun.write(bigFile, "x".repeat(6 * 1024 * 1024)); + await expect(filesystem.readProjectFile("big.txt")).rejects.toThrow("File too large"); + }); + }); }); diff --git a/src/web/components/FilePreviewModal.tsx b/src/web/components/FilePreviewModal.tsx new file mode 100644 index 000000000..7117ddbf5 --- /dev/null +++ b/src/web/components/FilePreviewModal.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from "react"; +import Modal from "./Modal"; +import { apiClient } from "../lib/api"; +import MermaidMarkdown from "./MermaidMarkdown"; +import { useTheme } from "../contexts/ThemeContext"; + +interface Props { + path: string; + onClose: () => void; +} + +const LANG_MAP: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + py: "python", + json: "json", + yaml: "yaml", + yml: "yaml", + css: "css", + scss: "scss", + html: "html", + xml: "xml", + sh: "bash", + bash: "bash", + zsh: "zsh", + go: "go", + rs: "rust", + java: "java", + cpp: "cpp", + c: "c", + cs: "csharp", + php: "php", + rb: "ruby", + sql: "sql", + dockerfile: "dockerfile", + md: "markdown", +}; + +function getLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() || ""; + return LANG_MAP[ext] || ext; +} + +function wrapInCodeBlock(content: string, language: string): string { + // Use longer fence if content contains triple backticks to avoid premature closing + const hasCodeFence = /\n```/.test(content); + const fence = hasCodeFence ? "````" : "```"; + return `${fence}${language}\n${content}\n${fence}`; +} + +export const FilePreviewModal: React.FC = ({ path, onClose }) => { + const { theme } = useTheme(); + const [content, setContent] = useState(""); + const [filePath, setFilePath] = useState(""); + const [lineStart, setLineStart] = useState(); + const [lineEnd, setLineEnd] = useState(); + const [totalLines, setTotalLines] = useState(0); + const [isMarkdown, setIsMarkdown] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const data = await apiClient.fetchFileContent(path); + if (cancelled) return; + setContent(data.content); + setFilePath(data.path); + setLineStart(data.lineStart); + setLineEnd(data.lineEnd); + setTotalLines(data.totalLines); + setIsMarkdown(data.isMarkdown); + } catch (err) { + if (cancelled) return; + const message = err instanceof Error ? err.message : "Failed to load file"; + setError(message); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [path]); + + const titleParts = [filePath || path]; + if (lineStart !== undefined && lineEnd !== undefined) { + titleParts.push(`(lines ${lineStart}-${lineEnd})`); + } else if (lineStart !== undefined) { + titleParts.push(`(line ${lineStart})`); + } + const title = titleParts.join(" "); + + const isPartial = lineStart !== undefined && lineEnd !== undefined; + const startLineNumber = (lineStart ?? 1) - 1; + + return ( + + {loading &&
Loading...
} + {error &&
{error}
} + {!loading && !error && ( +
+
+ {totalLines} line{totalLines !== 1 ? "s" : ""} total + {isPartial && ( + + showing lines {lineStart}-{lineEnd} + + )} +
+ {isMarkdown ? ( +
+ +
+ ) : ( +
+ {isPartial && ( +
+ Lines {lineStart}-{lineEnd} of {totalLines} +
+ )} +
+ +
+
+ )} +
+ )} + +
+ ); +}; + +export default FilePreviewModal; diff --git a/src/web/components/MermaidMarkdown.tsx b/src/web/components/MermaidMarkdown.tsx index 106b56fdd..701c953f7 100644 --- a/src/web/components/MermaidMarkdown.tsx +++ b/src/web/components/MermaidMarkdown.tsx @@ -1,9 +1,11 @@ -import { useEffect, useRef } from "react"; +import React, { useEffect, useRef } from "react"; import MDEditor from "@uiw/react-md-editor"; import { renderMermaidIn } from "../utils/mermaid"; +import { apiClient } from "../lib/api"; interface Props { source: string; + onFileClick?: (path: string) => void; } const URI_AUTOLINK_PREFIX_REGEX = /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\u0000-\u0020]*>/; @@ -19,7 +21,14 @@ function sanitizeMarkdownSource(source: string): string { }); } -export default function MermaidMarkdown({ source }: Props) { +function isExternalLink(href?: string): boolean { + if (!href) return true; + if (href.startsWith("#")) return false; + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return true; + return false; +} + +export default function MermaidMarkdown({ source, onFileClick }: Props) { const ref = useRef(null); const safeSource = sanitizeMarkdownSource(source); @@ -37,9 +46,50 @@ export default function MermaidMarkdown({ source }: Props) { return () => cancelAnimationFrame(frameId); }, [safeSource]); + const LinkComponent = React.useCallback( + ({ href, children }: { href?: string; children?: React.ReactNode }) => { + if (isExternalLink(href)) { + return ( + + {children} + + ); + } + + if (!onFileClick) { + return {children}; + } + + const handleClick = async (e: React.MouseEvent) => { + e.preventDefault(); + if (!href) return; + try { + // Verify file exists before opening preview + await apiClient.fetchFileContent(href); + onFileClick(href); + } catch { + // File not found or inaccessible: fall back to normal link behavior + window.open(href, "_blank"); + } + }; + + return ( + + {children} + + ); + }, + [onFileClick], + ); + return (
- +
); } diff --git a/src/web/components/TaskDetailsModal.tsx b/src/web/components/TaskDetailsModal.tsx index 45724ac80..b28c9a18a 100644 --- a/src/web/components/TaskDetailsModal.tsx +++ b/src/web/components/TaskDetailsModal.tsx @@ -6,6 +6,7 @@ import { useTheme } from "../contexts/ThemeContext"; import MDEditor from "@uiw/react-md-editor"; import AcceptanceCriteriaEditor from "./AcceptanceCriteriaEditor"; import MermaidMarkdown from './MermaidMarkdown'; +import FilePreviewModal from "./FilePreviewModal"; import ChipInput from "./ChipInput"; import DependencyInput from "./DependencyInput"; import { formatStoredUtcDateForDisplay } from "../utils/date-display"; @@ -227,6 +228,7 @@ export const TaskDetailsModal: React.FC = ({ const [references, setReferences] = useState(task?.references || []); const [milestone, setMilestone] = useState(task?.milestone || ""); const [availableTasks, setAvailableTasks] = useState([]); + const [previewFilePath, setPreviewFilePath] = useState(null); const milestoneSelectionValue = resolveMilestoneToId(milestone); const hasMilestoneSelection = (milestoneEntities ?? []).some((milestoneEntity) => milestoneEntity.id === milestoneSelectionValue); @@ -564,6 +566,7 @@ export const TaskDetailsModal: React.FC = ({ const documentation = task?.documentation ?? []; return ( + <> { @@ -665,7 +668,7 @@ export const TaskDetailsModal: React.FC = ({ {mode === "preview" ? ( description ? (
- + setPreviewFilePath(path)} />
) : (
No description
@@ -702,9 +705,13 @@ export const TaskDetailsModal: React.FC = ({ {ref} ) : ( - + )} {!isFromOtherBranch && ( @@ -776,9 +783,13 @@ export const TaskDetailsModal: React.FC = ({ {doc} ) : ( - + )} @@ -860,7 +871,7 @@ export const TaskDetailsModal: React.FC = ({ {mode === "preview" ? ( plan ? (
- + setPreviewFilePath(path)} />
) : (
No plan
@@ -884,7 +895,7 @@ export const TaskDetailsModal: React.FC = ({ {mode === "preview" ? ( notes ? (
- + setPreviewFilePath(path)} />
) : (
No notes
@@ -908,7 +919,7 @@ export const TaskDetailsModal: React.FC = ({ {mode === "preview" ? (
- + setPreviewFilePath(path)} />
) : (
@@ -1068,6 +1079,13 @@ export const TaskDetailsModal: React.FC = ({
+ {previewFilePath && ( + setPreviewFilePath(null)} + /> + )} + ); }; diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index 8f811a2eb..3ff3ae5e2 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -533,6 +533,24 @@ export class ApiClient { return this.fetchJson(`${API_BASE}/status`); } + async fetchFileContent(path: string): Promise<{ + content: string; + path: string; + lineStart?: number; + lineEnd?: number; + totalLines: number; + isMarkdown: boolean; + }> { + return this.fetchJson<{ + content: string; + path: string; + lineStart?: number; + lineEnd?: number; + totalLines: number; + isMarkdown: boolean; + }>(`${API_BASE}/file-content?path=${encodeURIComponent(path)}`); + } + async initializeProject(options: { projectName: string; backlogDirectory?: string;