mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
Fix annotate terminal startup in large folders
Avoid starving folder annotate sessions by excluding agent work directories and capping folder file-browser scans. Improve the annotate WebTUI panel startup/stop behavior.
This commit is contained in:
@@ -251,12 +251,8 @@ export const AnnotateAgentTerminalPanel = forwardRef<
|
||||
stopRequestedRef.current = true;
|
||||
const session = sessionRef.current;
|
||||
if (!session) {
|
||||
if (startedAgentId) {
|
||||
closeAfterStopRef.current = closeAfterStop;
|
||||
setStatus("stopping");
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
closeAfterStopRef.current = false;
|
||||
stopRequestedRef.current = false;
|
||||
setStartedAgentId(null);
|
||||
setStatus("idle");
|
||||
@@ -276,7 +272,7 @@ export const AnnotateAgentTerminalPanel = forwardRef<
|
||||
sessionRef.current?.pty.kill();
|
||||
if (closeAfterStopRef.current) onClose();
|
||||
}, 1400));
|
||||
}, [clearTimers, onClose, onSessionActiveChange, onSessionReadyChange, startedAgentId]);
|
||||
}, [clearTimers, onClose, onSessionActiveChange, onSessionReadyChange]);
|
||||
|
||||
const sendMessage = useCallback((message: string) => {
|
||||
const text = message.trim();
|
||||
@@ -348,7 +344,12 @@ export const AnnotateAgentTerminalPanel = forwardRef<
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1" style={terminalTheme.shellStyle}>
|
||||
<div className="relative min-h-0 flex-1" style={terminalTheme.shellStyle}>
|
||||
{status === "starting" && (
|
||||
<div className="pointer-events-none absolute left-3 top-3 z-10 rounded border border-border/50 bg-card/95 px-2 py-1 text-[11px] text-muted-foreground shadow-sm">
|
||||
Starting terminal...
|
||||
</div>
|
||||
)}
|
||||
<WebTuiTerminal
|
||||
key={startedAgentId}
|
||||
backend={backend}
|
||||
@@ -357,7 +358,7 @@ export const AnnotateAgentTerminalPanel = forwardRef<
|
||||
prompt={null}
|
||||
terminalOptions={terminalOptions}
|
||||
terminalColorScheme={terminalTheme.colorScheme}
|
||||
terminalGpuAcceleration="auto"
|
||||
terminalGpuAcceleration="off"
|
||||
fontZoom={AGENT_TERMINAL_FONT_ZOOM}
|
||||
className="h-full border-0"
|
||||
onReady={(session) => {
|
||||
|
||||
@@ -219,4 +219,35 @@ describe("handleFileBrowserFiles", () => {
|
||||
expect(data.workspaceStatus.totals.files).toBe(0);
|
||||
expect(data.workspaceStatus.files).toEqual({});
|
||||
});
|
||||
|
||||
test("caps large folder walks", async () => {
|
||||
const root = makeTempDir("plannotator-files-cap-");
|
||||
writeTempFile(root, "docs/a.md", "a\n");
|
||||
writeTempFile(root, "docs/b.md", "b\n");
|
||||
writeTempFile(root, "docs/c.md", "c\n");
|
||||
const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES;
|
||||
process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "2";
|
||||
|
||||
try {
|
||||
const url = new URL("http://localhost/api/reference/files");
|
||||
url.searchParams.set("dirPath", root);
|
||||
const res = await handleFileBrowserFiles(new Request(url.toString()));
|
||||
const data = await res.json() as {
|
||||
tree: VaultNode[];
|
||||
truncated: boolean;
|
||||
fileLimit: number;
|
||||
};
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(flattenTree(data.tree)).toHaveLength(2);
|
||||
expect(data.truncated).toBe(true);
|
||||
expect(data.fileLimit).toBe(2);
|
||||
} finally {
|
||||
if (previousLimit === undefined) {
|
||||
delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES;
|
||||
} else {
|
||||
process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,12 +543,34 @@ export async function handleObsidianDoc(req: Request): Promise<Response> {
|
||||
// --- File Browser ---
|
||||
|
||||
const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i;
|
||||
const DEFAULT_FILE_BROWSER_MAX_FILES = 5_000;
|
||||
|
||||
function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean {
|
||||
return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath);
|
||||
}
|
||||
|
||||
async function walkFileBrowserFiles(dir: string, root: string, files: Set<string>): Promise<void> {
|
||||
function getFileBrowserMaxFiles(): number {
|
||||
const value = Number.parseInt(process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES ?? "", 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_FILE_BROWSER_MAX_FILES;
|
||||
}
|
||||
|
||||
type FileBrowserWalkState = {
|
||||
files: Set<string>;
|
||||
limit: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function addFileBrowserFile(state: FileBrowserWalkState, relativePath: string): void {
|
||||
if (state.files.has(relativePath)) return;
|
||||
if (state.files.size >= state.limit) {
|
||||
state.truncated = true;
|
||||
return;
|
||||
}
|
||||
state.files.add(relativePath);
|
||||
}
|
||||
|
||||
async function walkFileBrowserFiles(dir: string, root: string, state: FileBrowserWalkState): Promise<void> {
|
||||
if (state.truncated) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
@@ -557,14 +579,15 @@ async function walkFileBrowserFiles(dir: string, root: string, files: Set<string
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (state.truncated) return;
|
||||
const fullPath = join(dir, entry.name);
|
||||
const relativePath = relative(root, fullPath).replace(/\\/g, "/");
|
||||
if (entry.isDirectory()) {
|
||||
if (isFileBrowserExcludedPath(relativePath)) continue;
|
||||
await walkFileBrowserFiles(fullPath, root, files);
|
||||
await walkFileBrowserFiles(fullPath, root, state);
|
||||
} else if (entry.isFile() && FILE_BROWSER_EXTENSIONS.test(entry.name)) {
|
||||
if (isFileBrowserExcludedPath(relativePath)) continue;
|
||||
files.add(relativePath);
|
||||
addFileBrowserFile(state, relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,16 +609,26 @@ export async function handleFileBrowserFiles(req: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const files = new Set<string>();
|
||||
await walkFileBrowserFiles(resolvedDir, resolvedDir, files);
|
||||
const state: FileBrowserWalkState = {
|
||||
files: new Set<string>(),
|
||||
limit: getFileBrowserMaxFiles(),
|
||||
truncated: false,
|
||||
};
|
||||
await walkFileBrowserFiles(resolvedDir, resolvedDir, state);
|
||||
const workspaceStatus = filterWorkspaceStatusForDirectory(await getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile);
|
||||
for (const match of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) {
|
||||
files.add(match);
|
||||
addFileBrowserFile(state, match);
|
||||
if (state.truncated) break;
|
||||
}
|
||||
const sortedFiles = [...files].sort();
|
||||
const sortedFiles = [...state.files].sort();
|
||||
|
||||
const tree = buildFileTree(sortedFiles);
|
||||
return Response.json({ tree, workspaceStatus });
|
||||
return Response.json({
|
||||
tree,
|
||||
workspaceStatus,
|
||||
truncated: state.truncated,
|
||||
fileLimit: state.limit,
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Failed to list directory files" },
|
||||
|
||||
@@ -9,6 +9,8 @@ describe("isFileBrowserExcludedPath", () => {
|
||||
expect(isFileBrowserExcludedPath("docs/node_modules/pkg/readme.md")).toBe(true);
|
||||
expect(isFileBrowserExcludedPath("docs/dist")).toBe(true);
|
||||
expect(isFileBrowserExcludedPath("docs/dist/generated.md")).toBe(true);
|
||||
expect(isFileBrowserExcludedPath(".claude/worktrees/session/plan.md")).toBe(true);
|
||||
expect(isFileBrowserExcludedPath("packages/app/.agents/skills/review/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches exact path segments only", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export const FILE_BROWSER_EXCLUDED = [
|
||||
"node_modules/",
|
||||
".git/",
|
||||
".claude/",
|
||||
".agents/",
|
||||
"dist/",
|
||||
"build/",
|
||||
".next/",
|
||||
|
||||
Reference in New Issue
Block a user