/ extensions / files-touched.ts
files-touched.ts
1 /** 2 * Files Touched 3 * 4 * /files-touched command lists all files the model has read/written/edited/moved/deleted in the active 5 * session branch by native Pi tools and/or the tools of repopprompt-cli and repoprompt-mcp, coalesced by 6 * normalized path and sorted newest first. Selecting a file opens it in VS Code. 7 */ 8 9 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; 10 import { DynamicBorder } from "@mariozechner/pi-coding-agent"; 11 import { Container, Key, matchesKey, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; 12 13 import { collectFilesTouched, type FilesTouchedEntry } from "./_shared/files-touched-core.ts"; 14 15 export default function (pi: ExtensionAPI) { 16 pi.registerCommand("files-touched", { 17 description: "Show files read/written/edited/moved/deleted in this session", 18 handler: async (_args, ctx) => { 19 if (!ctx.hasUI) { 20 ctx.ui.notify("No UI available", "error"); 21 return; 22 } 23 24 const files = collectFilesTouched(ctx.sessionManager.getBranch(), ctx.cwd); 25 if (files.length === 0) { 26 ctx.ui.notify("No files read/written/edited/moved/deleted in this session", "info"); 27 return; 28 } 29 30 const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; 31 const quoteCmdArg = (value: string) => `"${value.replace(/"/g, '""')}"`; 32 33 const openWithCode = async (path: string) => { 34 if (process.platform === "win32") { 35 if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(path)) { 36 ctx.ui.notify( 37 `Refusing to open ${path}: path contains Windows cmd metacharacters (& | < > ^ % or newline).`, 38 "error", 39 ); 40 return null; 41 } 42 const commandLine = `code -g ${quoteCmdArg(path)}`; 43 return pi.exec("cmd", ["/d", "/s", "/c", commandLine], { cwd: ctx.cwd }); 44 } 45 return pi.exec("code", ["-g", path], { cwd: ctx.cwd }); 46 }; 47 48 const openSelected = async (file: FilesTouchedEntry): Promise<void> => { 49 try { 50 const openResult = await openWithCode(file.path); 51 if (!openResult) return; 52 if (openResult.code !== 0) { 53 const openStderr = openResult.stderr.trim(); 54 ctx.ui.notify( 55 `Failed to open ${file.path} (exit ${openResult.code})${openStderr ? `: ${openStderr}` : ""}`, 56 "error", 57 ); 58 } 59 } catch (error) { 60 const message = error instanceof Error ? error.message : String(error); 61 ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error"); 62 } 63 }; 64 65 await ctx.ui.custom<void>((tui, theme, _kb, done) => { 66 const container = new Container(); 67 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 68 container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0)); 69 70 const items: SelectItem[] = files.map((file) => { 71 const ops: string[] = []; 72 if (file.operations.has("read")) ops.push(theme.fg("muted", "R")); 73 if (file.operations.has("write")) ops.push(theme.fg("success", "W")); 74 if (file.operations.has("edit")) ops.push(theme.fg("warning", "E")); 75 if (file.operations.has("move")) ops.push(theme.fg("accent", "M")); 76 if (file.operations.has("delete")) ops.push(theme.fg("error", "D")); 77 78 return { 79 value: file, 80 label: `${ops.join("")} ${file.displayPath}`, 81 }; 82 }); 83 84 const visibleRows = Math.min(files.length, 15); 85 let currentIndex = 0; 86 87 const selectList = new SelectList(items, visibleRows, { 88 selectedPrefix: (t) => theme.fg("accent", t), 89 selectedText: (t) => t, 90 description: (t) => theme.fg("muted", t), 91 scrollInfo: (t) => theme.fg("dim", t), 92 noMatch: (t) => theme.fg("warning", t), 93 }); 94 selectList.onSelect = (item) => { 95 void openSelected(item.value as FilesTouchedEntry); 96 }; 97 selectList.onCancel = () => done(); 98 selectList.onSelectionChange = (item) => { 99 currentIndex = items.indexOf(item); 100 }; 101 container.addChild(selectList); 102 103 container.addChild( 104 new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0), 105 ); 106 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); 107 108 return { 109 render: (w) => container.render(w), 110 invalidate: () => container.invalidate(), 111 handleInput: (data) => { 112 if (matchesKey(data, Key.left)) { 113 currentIndex = Math.max(0, currentIndex - visibleRows); 114 selectList.setSelectedIndex(currentIndex); 115 } else if (matchesKey(data, Key.right)) { 116 currentIndex = Math.min(items.length - 1, currentIndex + visibleRows); 117 selectList.setSelectedIndex(currentIndex); 118 } else { 119 selectList.handleInput(data); 120 } 121 tui.requestRender(); 122 }, 123 }; 124 }); 125 }, 126 }); 127 }