feat(coding-agent): added workspace-tree context APIs for system prompts
- Added `WorkspaceTree` and `buildWorkspaceTree` APIs for working-directory tree rendering with limits. - Extended `buildSystemPrompt` and `createAgentSession` to resolve and pass workspace tree context for system prompts. - Updated the system prompt template to include a `<workspace-tree>` section with truncation notices before append output. - Added workspace-tree and system-prompt tests covering sorting, truncation, exclusions, and prompt ordering.
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed the top-level `sel` parameter from the `read` tool schema, requiring callers to migrate to `path`-embedded selectors (for example `path:50-100`, `path:raw`, or `https://...:L1-L40`)
|
||||
|
||||
### Added
|
||||
|
||||
- Added `buildWorkspaceTree` and `WorkspaceTree` exports so callers can precompute and pass a workspace context to prompt generation
|
||||
- Added `workspaceTree` support to `buildSystemPrompt` options to reuse a prebuilt directory snapshot
|
||||
- Added `read.summarize.enabled`, `read.summarize.minBodyLines`, and `read.summarize.minCommentLines` settings to control whether `read` returns structural summaries and how many multiline body/comment lines are collapsed
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed generated system prompts to include a working-directory tree block after directory context, showing recent files/directories (depth ≤ 3) and truncation notices when entries are elided
|
||||
- Changed `read` summary rendering to merge opening- and closing-brace boundaries around elided sections into a single `..` line (including closers like `};` or `})`), reducing those segments to one concise anchored summary line
|
||||
- Changed default `read` output for parseable code files without an explicit selector to return a structural summary instead of full verbatim lines, while still supporting full output for `:raw` and explicit ranges
|
||||
- Changed truncation/pagination hints in read, archive, and SQLite outputs to use colon syntax (`Use :<offset>`) when continuing reads
|
||||
|
||||
@@ -34,6 +34,17 @@ Some directories may have their own rules. Deeper rules override higher ones.
|
||||
</dir-context>
|
||||
{{/if}}
|
||||
|
||||
{{#if workspaceTree.rendered}}
|
||||
<workspace-tree>
|
||||
Working directory layout (sorted by mtime, recent first; depth ≤ 3):
|
||||
{{workspaceTree.rendered}}
|
||||
{{#if workspaceTree.truncated}}
|
||||
(some entries elided to keep the tree short — use `find`/`read` to drill in)
|
||||
{{/if}}
|
||||
</workspace-tree>
|
||||
|
||||
{{/if}}
|
||||
|
||||
{{#if appendPrompt}}
|
||||
{{appendPrompt}}
|
||||
{{/if}}
|
||||
|
||||
@@ -140,6 +140,7 @@ import { wrapToolWithMetaNotice } from "./tools/output-meta";
|
||||
import { queueResolveHandler } from "./tools/resolve";
|
||||
import { EventBus } from "./utils/event-bus";
|
||||
import { buildNamedToolChoice } from "./utils/tool-choice";
|
||||
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
// Types
|
||||
export interface CreateAgentSessionOptions {
|
||||
@@ -270,6 +271,7 @@ export type { Skill } from "./extensibility/skills";
|
||||
export type { FileSlashCommand } from "./extensibility/slash-commands";
|
||||
export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp";
|
||||
export type { Tool } from "./tools";
|
||||
export { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
export {
|
||||
// Individual tool classes (for custom usage)
|
||||
@@ -680,6 +682,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// (~200ms on large repos) and only needs `cwd`, so it can overlap with everything that follows.
|
||||
const agentsMdSearchPromise: Promise<AgentsMdSearch> = logger.time("buildAgentsMdSearch", buildAgentsMdSearch, cwd);
|
||||
agentsMdSearchPromise.catch(() => {});
|
||||
const workspaceTreePromise: Promise<WorkspaceTree> = logger.time("buildWorkspaceTree", buildWorkspaceTree, cwd);
|
||||
workspaceTreePromise.catch(() => {});
|
||||
|
||||
// Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited
|
||||
// at their respective consumer sites. Their work can overlap with model resolution, secret loading,
|
||||
@@ -1380,6 +1384,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
eagerTasks,
|
||||
secretsEnabled,
|
||||
agentsMdSearch: agentsMdSearchPromise,
|
||||
workspaceTree: workspaceTreePromise,
|
||||
});
|
||||
|
||||
if (options.systemPrompt === undefined) {
|
||||
@@ -1404,6 +1409,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
eagerTasks,
|
||||
secretsEnabled,
|
||||
agentsMdSearch: agentsMdSearchPromise,
|
||||
workspaceTree: workspaceTreePromise,
|
||||
});
|
||||
}
|
||||
return options.systemPrompt(defaultPrompt);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
|
||||
import { loadSkills, type Skill } from "./extensibility/skills";
|
||||
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
||||
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
||||
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
interface AlwaysApplyRule {
|
||||
name: string;
|
||||
@@ -409,6 +410,8 @@ export interface BuildSystemPromptOptions {
|
||||
secretsEnabled?: boolean;
|
||||
/** Pre-loaded AGENTS.md search (skips discovery if provided). May be a Promise to allow early kick-off. */
|
||||
agentsMdSearch?: AgentsMdSearch | Promise<AgentsMdSearch>;
|
||||
/** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */
|
||||
workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
|
||||
}
|
||||
|
||||
/** Build the system prompt with tools, guidelines, and context */
|
||||
@@ -435,6 +438,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
eagerTasks = false,
|
||||
secretsEnabled = false,
|
||||
agentsMdSearch: providedAgentsMdSearch,
|
||||
workspaceTree: providedWorkspaceTree,
|
||||
} = options;
|
||||
const resolvedCwd = cwd ?? getProjectDir();
|
||||
|
||||
@@ -449,6 +453,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
providedAgentsMdSearch !== undefined
|
||||
? Promise.resolve(providedAgentsMdSearch)
|
||||
: logger.time("buildAgentsMdSearch", buildAgentsMdSearch, resolvedCwd);
|
||||
const workspaceTreePromise =
|
||||
providedWorkspaceTree !== undefined
|
||||
? Promise.resolve(providedWorkspaceTree)
|
||||
: logger.time("buildWorkspaceTree", buildWorkspaceTree, resolvedCwd);
|
||||
const skillsPromise: Promise<Skill[]> =
|
||||
providedSkills !== undefined
|
||||
? Promise.resolve(providedSkills)
|
||||
@@ -463,6 +471,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
contextFilesPromise,
|
||||
agentsMdSearchPromise,
|
||||
skillsPromise,
|
||||
workspaceTreePromise,
|
||||
]).then(
|
||||
([
|
||||
resolvedCustomPrompt,
|
||||
@@ -471,6 +480,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
contextFiles,
|
||||
agentsMdSearch,
|
||||
skills,
|
||||
workspaceTree,
|
||||
]) => ({
|
||||
resolvedCustomPrompt,
|
||||
resolvedAppendPrompt,
|
||||
@@ -478,6 +488,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
contextFiles,
|
||||
agentsMdSearch,
|
||||
skills,
|
||||
workspaceTree,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
@@ -501,6 +512,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
pattern: `AGENTS.md depth ${AGENTS_MD_MIN_DEPTH}-${AGENTS_MD_MAX_DEPTH}`,
|
||||
files: [],
|
||||
};
|
||||
let workspaceTree: WorkspaceTree = {
|
||||
rootPath: resolvedCwd,
|
||||
rendered: "",
|
||||
truncated: false,
|
||||
totalLines: 0,
|
||||
};
|
||||
let skills: Skill[] = providedSkills ?? [];
|
||||
|
||||
if (prepResult.type === "timeout") {
|
||||
@@ -524,6 +541,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
|
||||
agentsMdSearch = prepResult.value.agentsMdSearch;
|
||||
skills = prepResult.value.skills;
|
||||
workspaceTree = prepResult.value.workspaceTree;
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
@@ -578,6 +596,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
environment,
|
||||
contextFiles,
|
||||
agentsMdSearch,
|
||||
workspaceTree,
|
||||
skills: filteredSkills,
|
||||
rules: rules ?? [],
|
||||
alwaysApplyRules: injectedAlwaysApplyRules,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import * as path from "node:path";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
|
||||
|
||||
export interface WorkspaceTree {
|
||||
rootPath: string;
|
||||
rendered: string;
|
||||
truncated: boolean;
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
const WORKSPACE_TREE_MAX_DEPTH = 3;
|
||||
const WORKSPACE_TREE_DIR_LIMIT = 12;
|
||||
const WORKSPACE_TREE_LINE_CAP = 120;
|
||||
const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".venv",
|
||||
".cache",
|
||||
".turbo",
|
||||
".parcel-cache",
|
||||
"coverage",
|
||||
]);
|
||||
|
||||
const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
|
||||
|
||||
interface WorkspaceTreeNode {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
depth: number;
|
||||
isDirectory: boolean;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
children: WorkspaceTreeNode[];
|
||||
droppedChildCount: number;
|
||||
}
|
||||
|
||||
interface RenderLine {
|
||||
label: string;
|
||||
depth: number;
|
||||
size?: string;
|
||||
age?: string;
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
function emptyWorkspaceTree(rootPath: string): WorkspaceTree {
|
||||
return {
|
||||
rootPath,
|
||||
rendered: "",
|
||||
truncated: false,
|
||||
totalLines: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function compareByRecency(a: WorkspaceTreeNode, b: WorkspaceTreeNode): number {
|
||||
const mtimeCompare = b.mtimeMs - a.mtimeMs;
|
||||
if (mtimeCompare !== 0) return mtimeCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function childRelativePath(parentRelativePath: string, name: string): string {
|
||||
return parentRelativePath ? `${parentRelativePath}/${name}` : name;
|
||||
}
|
||||
|
||||
function escapeGlobSegment(segment: string): string {
|
||||
return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join("");
|
||||
}
|
||||
|
||||
function directChildPattern(parentRelativePath: string): string {
|
||||
if (!parentRelativePath) return "*";
|
||||
return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`;
|
||||
}
|
||||
|
||||
function matchChildName(parentRelativePath: string, matchPath: string): string | null {
|
||||
if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath;
|
||||
const prefix = `${parentRelativePath}/`;
|
||||
if (!matchPath.startsWith(prefix)) return null;
|
||||
const name = matchPath.slice(prefix.length);
|
||||
return name.includes("/") ? null : name;
|
||||
}
|
||||
|
||||
async function listWorkspaceTreeChildren(rootPath: string, parent: WorkspaceTreeNode): Promise<WorkspaceTreeNode[]> {
|
||||
const result = await glob({
|
||||
pattern: directChildPattern(parent.relativePath),
|
||||
path: rootPath,
|
||||
recursive: false,
|
||||
hidden: false,
|
||||
gitignore: true,
|
||||
cache: true,
|
||||
});
|
||||
|
||||
const children = await Promise.all(
|
||||
result.matches.map(async (match): Promise<WorkspaceTreeNode | null> => {
|
||||
const name = matchChildName(parent.relativePath, match.path);
|
||||
if (!name) return null;
|
||||
if (name.startsWith(".")) return null;
|
||||
const absolutePath = path.join(rootPath, childRelativePath(parent.relativePath, name));
|
||||
try {
|
||||
const stat = await Bun.file(absolutePath).stat();
|
||||
const isDirectory = stat.isDirectory();
|
||||
if (isDirectory && WORKSPACE_TREE_EXCLUDED_DIRS.has(name)) return null;
|
||||
return {
|
||||
name,
|
||||
relativePath: childRelativePath(parent.relativePath, name),
|
||||
depth: parent.depth + 1,
|
||||
isDirectory,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
children: [],
|
||||
droppedChildCount: 0,
|
||||
} satisfies WorkspaceTreeNode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return children.filter((child): child is WorkspaceTreeNode => child !== null).sort(compareByRecency);
|
||||
}
|
||||
|
||||
function applyDirectoryLimit(children: WorkspaceTreeNode[]): {
|
||||
visibleChildren: WorkspaceTreeNode[];
|
||||
droppedCount: number;
|
||||
} {
|
||||
if (children.length <= WORKSPACE_TREE_DIR_LIMIT) {
|
||||
return { visibleChildren: children, droppedCount: 0 };
|
||||
}
|
||||
|
||||
const recentChildren = children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
|
||||
const oldestChild = children[children.length - 1];
|
||||
return {
|
||||
visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren,
|
||||
droppedCount: children.length - WORKSPACE_TREE_DIR_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectWorkspaceTree(rootPath: string): Promise<{ root: WorkspaceTreeNode; truncated: boolean }> {
|
||||
const rootStat = await Bun.file(rootPath).stat();
|
||||
const root: WorkspaceTreeNode = {
|
||||
name: ".",
|
||||
relativePath: "",
|
||||
depth: 0,
|
||||
isDirectory: true,
|
||||
mtimeMs: rootStat.mtimeMs,
|
||||
size: rootStat.size,
|
||||
children: [],
|
||||
droppedChildCount: 0,
|
||||
};
|
||||
|
||||
let truncated = false;
|
||||
const queue: WorkspaceTreeNode[] = [root];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < queue.length) {
|
||||
const parent = queue[cursor];
|
||||
cursor += 1;
|
||||
if (!parent || parent.depth >= WORKSPACE_TREE_MAX_DEPTH) continue;
|
||||
|
||||
const children = await listWorkspaceTreeChildren(rootPath, parent);
|
||||
const limited = applyDirectoryLimit(children);
|
||||
parent.children = limited.visibleChildren;
|
||||
parent.droppedChildCount = limited.droppedCount;
|
||||
if (limited.droppedCount > 0) truncated = true;
|
||||
|
||||
for (const child of parent.children) {
|
||||
if (child.isDirectory) queue.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return { root, truncated };
|
||||
}
|
||||
|
||||
function formatNodeAge(nowMs: number, mtimeMs: number): string {
|
||||
const ageSeconds = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000));
|
||||
return formatAge(ageSeconds);
|
||||
}
|
||||
|
||||
function pushNodeLine(lines: RenderLine[], node: WorkspaceTreeNode, nowMs: number): void {
|
||||
if (node.depth === 0) {
|
||||
lines.push({ label: ".", depth: 0, isRoot: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const indent = " ".repeat(node.depth);
|
||||
const suffix = node.isDirectory ? "/" : "";
|
||||
lines.push({
|
||||
label: `${indent}- ${node.name}${suffix}`,
|
||||
depth: node.depth,
|
||||
size: node.isDirectory ? undefined : formatBytes(node.size),
|
||||
age: formatNodeAge(nowMs, node.mtimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
function pushDroppedChildrenLine(lines: RenderLine[], parent: WorkspaceTreeNode): void {
|
||||
if (parent.droppedChildCount <= 0) return;
|
||||
const childDepth = parent.depth + 1;
|
||||
const indent = " ".repeat(childDepth);
|
||||
lines.push({
|
||||
label: `${indent}- … ${parent.droppedChildCount} more`,
|
||||
depth: childDepth,
|
||||
});
|
||||
}
|
||||
|
||||
function collectRenderLines(node: WorkspaceTreeNode, nowMs: number, lines: RenderLine[]): void {
|
||||
pushNodeLine(lines, node, nowMs);
|
||||
|
||||
if (node.droppedChildCount > 0) {
|
||||
const recentChildren = node.children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
|
||||
const oldestChild = node.children[node.children.length - 1];
|
||||
for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
|
||||
pushDroppedChildrenLine(lines, node);
|
||||
if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of node.children) collectRenderLines(child, nowMs, lines);
|
||||
}
|
||||
|
||||
function applyLineCap(lines: RenderLine[]): { lines: RenderLine[]; elidedCount: number } {
|
||||
if (lines.length <= WORKSPACE_TREE_LINE_CAP) return { lines, elidedCount: 0 };
|
||||
|
||||
const targetLineCount = WORKSPACE_TREE_LINE_CAP - 1;
|
||||
const removeCount = lines.length - targetLineCount;
|
||||
const removable = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(item => !item.line.isRoot)
|
||||
.sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
|
||||
.slice(0, removeCount);
|
||||
const removedIndexes = new Set(removable.map(item => item.index));
|
||||
const cappedLines = lines.filter((_, index) => !removedIndexes.has(index));
|
||||
cappedLines.push({
|
||||
label: `… (${removeCount} lines elided beyond depth/cap)`,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return { lines: cappedLines, elidedCount: removeCount };
|
||||
}
|
||||
|
||||
function renderLines(lines: RenderLine[]): string {
|
||||
const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0);
|
||||
return lines
|
||||
.map(line => {
|
||||
if (!line.age) return line.label;
|
||||
const sizeColumn = (line.size ?? "").padEnd(8);
|
||||
return `${line.label.padEnd(maxLabelLength + 2)}${sizeColumn} ${line.age.padEnd(4)}`.trimEnd();
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
|
||||
const rootPath = path.resolve(cwd);
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const { root, truncated: directoryTruncated } = await collectWorkspaceTree(rootPath);
|
||||
const lines: RenderLine[] = [];
|
||||
collectRenderLines(root, nowMs, lines);
|
||||
const { lines: cappedLines, elidedCount } = applyLineCap(lines);
|
||||
return {
|
||||
rootPath,
|
||||
rendered: renderLines(cappedLines),
|
||||
truncated: directoryTruncated || elidedCount > 0,
|
||||
totalLines: cappedLines.length,
|
||||
};
|
||||
} catch {
|
||||
return emptyWorkspaceTree(rootPath);
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,35 @@ describe("system Handlebars prompt templates", () => {
|
||||
expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists");
|
||||
});
|
||||
|
||||
test("buildSystemPrompt renders workspace tree after directory context", async () => {
|
||||
await withTempDir(async dir => {
|
||||
const systemPrompt = await buildSystemPrompt({
|
||||
cwd: dir,
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
toolNames: ["read"],
|
||||
agentsMdSearch: {
|
||||
scopePath: ".",
|
||||
limit: 200,
|
||||
pattern: "AGENTS.md depth 1-4",
|
||||
files: ["packages/coding-agent/AGENTS.md"],
|
||||
},
|
||||
workspaceTree: {
|
||||
rootPath: dir,
|
||||
rendered: ".\n - src/ 1m",
|
||||
truncated: true,
|
||||
totalLines: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(systemPrompt).toContain("<workspace-tree>");
|
||||
expect(systemPrompt).toContain("Working directory layout (sorted by mtime, recent first; depth ≤ 3):");
|
||||
expect(systemPrompt).toContain("(some entries elided to keep the tree short");
|
||||
expect(systemPrompt.indexOf("</dir-context>")).toBeLessThan(systemPrompt.indexOf("<workspace-tree>"));
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSystemPrompt deduplicates always-apply rules already present in SYSTEM.md", async () => {
|
||||
const duplicateRule = ["Use static imports.", "", "Do not use dynamic loading."].join("\n");
|
||||
const distinctRule = "Validate inputs at boundaries.";
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { buildWorkspaceTree } from "@oh-my-pi/pi-coding-agent/workspace-tree";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-workspace-tree-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): Promise<void> {
|
||||
await Bun.write(filePath, content);
|
||||
const mtime = new Date(mtimeMs);
|
||||
await fs.utimes(filePath, mtime, mtime);
|
||||
}
|
||||
|
||||
async function touchDirWithMtime(dirPath: string, mtimeMs: number): Promise<void> {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
const mtime = new Date(mtimeMs);
|
||||
await fs.utimes(dirPath, mtime, mtime);
|
||||
}
|
||||
|
||||
function lineIndex(rendered: string, needle: string): number {
|
||||
return rendered.split("\n").findIndex(line => line.includes(needle));
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTree", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("sorts files and directories together by modification time", async () => {
|
||||
const cwd = await makeTempDir();
|
||||
const base = Date.now() - 60_000;
|
||||
const recentDir = path.join(cwd, "recent-dir");
|
||||
const staleDir = path.join(cwd, "stale-dir");
|
||||
await touchDirWithMtime(recentDir, base + 3_000);
|
||||
await touchDirWithMtime(staleDir, base);
|
||||
await writeFileWithMtime(path.join(cwd, "newest.txt"), "newest", base + 4_000);
|
||||
await writeFileWithMtime(path.join(cwd, "old.txt"), "old", base + 2_000);
|
||||
await touchDirWithMtime(recentDir, base + 3_000);
|
||||
await touchDirWithMtime(staleDir, base);
|
||||
|
||||
const tree = await buildWorkspaceTree(cwd);
|
||||
|
||||
expect(lineIndex(tree.rendered, "newest.txt")).toBeGreaterThan(-1);
|
||||
expect(lineIndex(tree.rendered, "recent-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "newest.txt"));
|
||||
expect(lineIndex(tree.rendered, "old.txt")).toBeGreaterThan(lineIndex(tree.rendered, "recent-dir/"));
|
||||
expect(lineIndex(tree.rendered, "stale-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "old.txt"));
|
||||
});
|
||||
|
||||
it("keeps the newest entries, a truncation marker, and the oldest entry per directory", async () => {
|
||||
const cwd = await makeTempDir();
|
||||
const base = Date.now() - 60_000;
|
||||
for (let i = 0; i < 13; i += 1) {
|
||||
const ageRank = String(i).padStart(2, "0");
|
||||
await writeFileWithMtime(path.join(cwd, `file-${ageRank}.txt`), ageRank, base + (13 - i) * 1_000);
|
||||
}
|
||||
|
||||
const tree = await buildWorkspaceTree(cwd);
|
||||
|
||||
expect(tree.truncated).toBe(true);
|
||||
expect(tree.rendered).toContain("… 1 more");
|
||||
expect(tree.rendered).toContain("file-00.txt");
|
||||
expect(tree.rendered).toContain("file-10.txt");
|
||||
expect(tree.rendered).not.toContain("file-11.txt");
|
||||
expect(tree.rendered).toContain("file-12.txt");
|
||||
expect(lineIndex(tree.rendered, "… 1 more")).toBeLessThan(lineIndex(tree.rendered, "file-12.txt"));
|
||||
});
|
||||
|
||||
it("enforces the depth cap and skips hidden, excluded, and gitignored paths", async () => {
|
||||
const cwd = await makeTempDir();
|
||||
await Bun.write(path.join(cwd, ".gitignore"), "ignored.txt\nignored-dir/\na/b/ignored-nested.txt\n");
|
||||
await writeFileWithMtime(path.join(cwd, "kept.txt"), "kept", Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, "ignored.txt"), "ignored", Date.now() - 1_000);
|
||||
await touchDirWithMtime(path.join(cwd, "ignored-dir"), Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, "ignored-dir", "child.txt"), "ignored", Date.now() - 1_000);
|
||||
await touchDirWithMtime(path.join(cwd, "node_modules"), Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, "node_modules", "pkg.js"), "ignored", Date.now() - 1_000);
|
||||
await touchDirWithMtime(path.join(cwd, ".git"), Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, ".git", "config"), "ignored", Date.now() - 1_000);
|
||||
await touchDirWithMtime(path.join(cwd, "a", "b", "c", "d"), Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, "a", "b", "c", "d", "deep.txt"), "deep", Date.now() - 1_000);
|
||||
await writeFileWithMtime(path.join(cwd, "a", "b", "ignored-nested.txt"), "ignored", Date.now() - 1_000);
|
||||
|
||||
const tree = await buildWorkspaceTree(cwd);
|
||||
|
||||
expect(tree.rendered).toContain("kept.txt");
|
||||
expect(tree.rendered).toContain("c/");
|
||||
expect(tree.rendered).not.toContain("ignored.txt");
|
||||
expect(tree.rendered).not.toContain("ignored-dir");
|
||||
expect(tree.rendered).not.toContain("ignored-nested.txt");
|
||||
expect(tree.rendered).not.toContain("node_modules");
|
||||
expect(tree.rendered).not.toContain(".git");
|
||||
expect(tree.rendered).not.toContain("d/");
|
||||
expect(tree.rendered).not.toContain("deep.txt");
|
||||
});
|
||||
|
||||
it("caps the rendered tree at the hard line limit", async () => {
|
||||
const cwd = await makeTempDir();
|
||||
const base = Date.now() - 60_000;
|
||||
for (let dirIndex = 0; dirIndex < 12; dirIndex += 1) {
|
||||
const dirName = `dir-${String(dirIndex).padStart(2, "0")}`;
|
||||
const dirPath = path.join(cwd, dirName);
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
for (let fileIndex = 0; fileIndex < 12; fileIndex += 1) {
|
||||
const fileName = `file-${String(fileIndex).padStart(2, "0")}.txt`;
|
||||
await writeFileWithMtime(path.join(dirPath, fileName), fileName, base + fileIndex);
|
||||
}
|
||||
await touchDirWithMtime(dirPath, base + dirIndex);
|
||||
}
|
||||
|
||||
const tree = await buildWorkspaceTree(cwd);
|
||||
const renderedLines = tree.rendered.split("\n");
|
||||
|
||||
expect(tree.truncated).toBe(true);
|
||||
expect(tree.totalLines).toBeLessThanOrEqual(120);
|
||||
expect(renderedLines.length).toBeLessThanOrEqual(120);
|
||||
expect(tree.rendered).toContain("lines elided beyond depth/cap");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user