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
Original file line number Diff line number Diff line change
@@ -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
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->

## 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

<!-- SECTION:PLAN:BEGIN -->
## 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
<!-- SECTION:PLAN:END -->
74 changes: 72 additions & 2 deletions src/file-system/operations.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof stat> extends Promise<infer T> ? 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;
Expand Down
29 changes: 29 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
Expand Down Expand Up @@ -1673,6 +1676,32 @@ export class BacklogServer {
}
}

private async handleGetFileContent(req: Request): Promise<Response> {
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<Response> {
try {
const config = await this.core.filesystem.loadConfig();
Expand Down
50 changes: 50 additions & 0 deletions src/test/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
Loading
Loading