pi-extensions 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
+ ## [0.1.10] - 2026-01-26
6
+
7
+ ### Fixed
8
+ - Treat git-reported directory entries as directories to avoid viewer errors
9
+ - Guard the viewer against opening directories directly
10
+ - Wrap delta diff output without breaking gutters and avoid truncation
11
+ - Add a safe fallback when `bat` fails to render with wrapping
12
+
5
13
  ## [0.1.9] - 2026-01-26
6
14
 
7
15
  ### Changed
@@ -1,6 +1,6 @@
1
1
  # files-widget
2
2
 
3
- In-terminal file browser and viewer for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal.
3
+ In-terminal file browser and diff viewer widget for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal and without interrupting your agent.
4
4
 
5
5
  ![Demo](demo.svg)
6
6
 
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
 
3
4
  import { MAX_TREE_DEPTH } from "./constants";
@@ -5,6 +6,14 @@ import type { DiffStats, FileNode, FlatNode } from "./types";
5
6
 
6
7
  const collator = new Intl.Collator(undefined, { sensitivity: "base" });
7
8
 
9
+ function isDirectoryPath(path: string): boolean {
10
+ try {
11
+ return statSync(path).isDirectory();
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
8
17
  function compareNodes(a: FileNode, b: FileNode): number {
9
18
  if (a.isDirectory !== b.isDirectory) {
10
19
  return a.isDirectory ? -1 : 1;
@@ -153,11 +162,41 @@ export function buildFileTreeFromPaths(
153
162
 
154
163
  const fileRelPath = parts.join("/");
155
164
  if (seenFiles.has(fileRelPath)) continue;
156
- seenFiles.add(fileRelPath);
157
165
 
158
166
  const filePath = join(cwd, fileRelPath);
159
- const fileGitStatus = gitStatus.get(fileRelPath);
167
+ const fileGitStatus = gitStatus.get(fileRelPath) ?? gitStatus.get(`${fileRelPath}/`);
160
168
  const fileDiffStats = diffStats.get(fileRelPath);
169
+ const existingDir = directoryMap.get(fileRelPath);
170
+
171
+ if (existingDir) {
172
+ if (fileGitStatus) {
173
+ existingDir.gitStatus = fileGitStatus;
174
+ }
175
+ if (fileDiffStats) {
176
+ existingDir.diffStats = fileDiffStats;
177
+ }
178
+ continue;
179
+ }
180
+
181
+ const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
182
+ if (isDirEntry) {
183
+ const depth = parts.length;
184
+ const dirNode: FileNode = {
185
+ name: fileName,
186
+ path: filePath,
187
+ isDirectory: true,
188
+ children: [],
189
+ expanded: depth < 1,
190
+ hasChangedChildren: false,
191
+ gitStatus: fileGitStatus,
192
+ diffStats: fileDiffStats,
193
+ };
194
+ directoryMap.set(fileRelPath, dirNode);
195
+ current.children?.push(dirNode);
196
+ continue;
197
+ }
198
+
199
+ seenFiles.add(fileRelPath);
161
200
 
162
201
  current.children?.push({
163
202
  name: fileName,
@@ -1,9 +1,157 @@
1
+ import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
1
2
  import { execSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
3
4
 
4
5
  import { isGitRepo } from "./git";
5
6
  import { hasCommand, stripLeadingEmptyLines } from "./utils";
6
7
 
8
+ const DIFF_CONTENT_PREFIXES = new Set(["+", "-", " "]);
9
+
10
+ function isDiffContentLine(line: string): boolean {
11
+ if (!line) return false;
12
+ if (!DIFF_CONTENT_PREFIXES.has(line[0])) return false;
13
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) return false;
14
+ return true;
15
+ }
16
+
17
+ function wrapLine(line: string, width: number): string[] {
18
+ if (width <= 0 || line.length <= width) {
19
+ return [line];
20
+ }
21
+ const wrapped: string[] = [];
22
+ for (let i = 0; i < line.length; i += width) {
23
+ wrapped.push(line.slice(i, i + width));
24
+ }
25
+ return wrapped;
26
+ }
27
+
28
+ function wrapDiffLines(lines: string[], width: number): string[] {
29
+ if (width <= 0) return lines;
30
+ const wrapped: string[] = [];
31
+ for (const line of lines) {
32
+ if (line.length <= width) {
33
+ wrapped.push(line);
34
+ continue;
35
+ }
36
+ if (isDiffContentLine(line)) {
37
+ const prefix = line[0];
38
+ const content = line.slice(1);
39
+ const contentWidth = Math.max(width - 1, 1);
40
+ for (const chunk of wrapLine(content, contentWidth)) {
41
+ wrapped.push(prefix + chunk);
42
+ }
43
+ } else {
44
+ wrapped.push(...wrapLine(line, width));
45
+ }
46
+ }
47
+ return wrapped;
48
+ }
49
+
50
+ function extractAnsiCode(str: string, pos: number): { length: number } | null {
51
+ if (pos >= str.length || str[pos] !== "\x1b") return null;
52
+ const next = str[pos + 1];
53
+ if (next === "[") {
54
+ let j = pos + 2;
55
+ while (j < str.length && !/[mGKHJ]/.test(str[j])) j++;
56
+ if (j < str.length) return { length: j + 1 - pos };
57
+ return null;
58
+ }
59
+ if (next === "]") {
60
+ let j = pos + 2;
61
+ while (j < str.length) {
62
+ if (str[j] === "\x07") return { length: j + 1 - pos };
63
+ if (str[j] === "\x1b" && str[j + 1] === "\\") return { length: j + 2 - pos };
64
+ j++;
65
+ }
66
+ return null;
67
+ }
68
+ if (next === "_") {
69
+ let j = pos + 2;
70
+ while (j < str.length) {
71
+ if (str[j] === "\x07") return { length: j + 1 - pos };
72
+ if (str[j] === "\x1b" && str[j + 1] === "\\") return { length: j + 2 - pos };
73
+ j++;
74
+ }
75
+ return null;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function stripAnsiCodes(line: string): string {
81
+ let result = "";
82
+ for (let i = 0; i < line.length;) {
83
+ const ansi = extractAnsiCode(line, i);
84
+ if (ansi) {
85
+ i += ansi.length;
86
+ continue;
87
+ }
88
+ result += line[i];
89
+ i += 1;
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function splitByVisibleWidth(line: string, width: number): { prefix: string; rest: string } {
95
+ if (width <= 0) return { prefix: "", rest: line };
96
+ let visible = 0;
97
+ let i = 0;
98
+ while (i < line.length) {
99
+ const ansi = extractAnsiCode(line, i);
100
+ if (ansi) {
101
+ i += ansi.length;
102
+ continue;
103
+ }
104
+ const charWidth = visibleWidth(line[i]);
105
+ if (visible + charWidth > width) break;
106
+ visible += charWidth;
107
+ i += 1;
108
+ }
109
+ return { prefix: line.slice(0, i), rest: line.slice(i) };
110
+ }
111
+
112
+ function maskDigits(line: string): string {
113
+ let result = "";
114
+ for (let i = 0; i < line.length;) {
115
+ const ansi = extractAnsiCode(line, i);
116
+ if (ansi) {
117
+ result += line.slice(i, i + ansi.length);
118
+ i += ansi.length;
119
+ continue;
120
+ }
121
+ const char = line[i];
122
+ result += char >= "0" && char <= "9" ? " " : char;
123
+ i += 1;
124
+ }
125
+ return result;
126
+ }
127
+
128
+ function wrapDeltaLine(line: string, width: number): string[] {
129
+ const clean = stripAnsiCodes(line);
130
+ let separatorIndex = clean.indexOf("│");
131
+ if (separatorIndex === -1) separatorIndex = clean.indexOf("|");
132
+ if (separatorIndex === -1) return wrapTextWithAnsi(line, width);
133
+
134
+ let prefixWidth = visibleWidth(clean.slice(0, separatorIndex + 1));
135
+ if (clean[separatorIndex + 1] === " ") prefixWidth += 1;
136
+ if (prefixWidth >= width) return wrapTextWithAnsi(line, width);
137
+
138
+ const { prefix, rest } = splitByVisibleWidth(line, prefixWidth);
139
+ const contentWidth = Math.max(width - visibleWidth(prefix), 1);
140
+ const continuationPrefix = maskDigits(prefix);
141
+ const wrappedContent = wrapTextWithAnsi(rest, contentWidth);
142
+
143
+ return wrappedContent.map((chunk, index) => (index === 0 ? prefix : continuationPrefix) + chunk);
144
+ }
145
+
146
+ function wrapDeltaLines(lines: string[], width: number): string[] {
147
+ if (width <= 0) return lines;
148
+ const wrapped: string[] = [];
149
+ for (const line of lines) {
150
+ wrapped.push(...wrapDeltaLine(line, width));
151
+ }
152
+ return wrapped;
153
+ }
154
+
7
155
  export function loadFileContent(
8
156
  filePath: string,
9
157
  cwd: string,
@@ -15,23 +163,31 @@ export function loadFileContent(
15
163
  const termWidth = width || process.stdout.columns || 80;
16
164
 
17
165
  try {
166
+ try {
167
+ if (statSync(filePath).isDirectory()) {
168
+ return ["Directory selected - expand it in the file tree instead of opening it."];
169
+ }
170
+ } catch {
171
+ // Ignore stat errors and fall through to normal handling
172
+ }
173
+
18
174
  if (diffMode && hasChanges && isGitRepo(cwd)) {
19
175
  try {
20
176
  // Try different diff strategies
21
177
  let diffOutput = "";
22
178
 
23
179
  // First try: unstaged changes
24
- const unstaged = execSync(`git diff -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
180
+ const unstaged = execSync(`git diff --no-color -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
25
181
  if (unstaged.trim()) {
26
182
  diffOutput = unstaged;
27
183
  } else {
28
184
  // Second try: staged changes
29
- const staged = execSync(`git diff --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
185
+ const staged = execSync(`git diff --no-color --cached -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
30
186
  if (staged.trim()) {
31
187
  diffOutput = staged;
32
188
  } else {
33
189
  // Third try: diff against HEAD (for new files that are staged)
34
- const headDiff = execSync(`git diff HEAD -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
190
+ const headDiff = execSync(`git diff --no-color HEAD -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" });
35
191
  if (headDiff.trim()) {
36
192
  diffOutput = headDiff;
37
193
  }
@@ -46,7 +202,7 @@ export function loadFileContent(
46
202
  // Pipe through delta with line numbers for better readability
47
203
  try {
48
204
  const deltaOutput = execSync(
49
- `delta --no-gitconfig --width=${termWidth} --line-numbers`,
205
+ `delta --no-gitconfig --width=${termWidth} --line-numbers --wrap-max-lines=unlimited --max-line-length=0`,
50
206
  {
51
207
  cwd,
52
208
  encoding: "utf-8",
@@ -55,13 +211,13 @@ export function loadFileContent(
55
211
  stdio: ["pipe", "pipe", "pipe"],
56
212
  }
57
213
  );
58
- return stripLeadingEmptyLines(deltaOutput.split("\n"));
214
+ return wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth);
59
215
  } catch {
60
216
  // Fall back to raw diff
61
217
  }
62
218
  }
63
219
 
64
- return diffOutput.split("\n");
220
+ return wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth);
65
221
  } catch (e: any) {
66
222
  return [`Diff error: ${e.message}`];
67
223
  }
@@ -79,10 +235,21 @@ export function loadFileContent(
79
235
  }
80
236
 
81
237
  if (hasCommand("bat")) {
82
- return execSync(
83
- `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
84
- { encoding: "utf-8", timeout: 10000 }
85
- ).split("\n");
238
+ try {
239
+ return execSync(
240
+ `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
241
+ { encoding: "utf-8", timeout: 10000 }
242
+ ).split("\n");
243
+ } catch {
244
+ try {
245
+ return execSync(
246
+ `bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
247
+ { encoding: "utf-8", timeout: 10000 }
248
+ ).split("\n");
249
+ } catch {
250
+ // Fall through to raw file read
251
+ }
252
+ }
86
253
  }
87
254
 
88
255
  const raw = readFileSync(filePath, "utf-8");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -14,6 +14,10 @@
14
14
  },
15
15
  "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
16
  "homepage": "https://github.com/tmustier/pi-extensions/tree/main/files-widget",
17
+ "peerDependencies": {
18
+ "@mariozechner/pi-coding-agent": "^0.50.0",
19
+ "@mariozechner/pi-tui": "^0.50.0"
20
+ },
17
21
  "pi": {
18
22
  "extensions": [
19
23
  "index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "pi-package"