@tmustier/pi-files-widget 0.1.20 → 0.2.0

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.
package/file-viewer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
1
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { execSync } from "node:child_process";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
 
package/git.ts CHANGED
@@ -11,6 +11,27 @@ export function isGitRepo(cwd: string): boolean {
11
11
  }
12
12
  }
13
13
 
14
+ /**
15
+ * Path of `cwd` relative to the repository top-level (e.g. "app/"), or "" when
16
+ * `cwd` is the top-level itself. `git status --porcelain` reports paths relative
17
+ * to the repository root, while `git ls-files` (and this widget's node keys) are
18
+ * relative to `cwd` — this prefix lets us translate between the two.
19
+ */
20
+ function getGitPathPrefix(cwd: string): string {
21
+ try {
22
+ return execSync("git rev-parse --show-prefix", { cwd, encoding: "utf-8", timeout: 2000, stdio: "pipe" }).trim();
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ /** Convert a repo-root-relative path to a cwd-relative one; null if outside cwd. */
29
+ function stripPathPrefix(filePath: string, prefix: string): string | null {
30
+ if (!prefix) return filePath;
31
+ if (filePath.startsWith(prefix)) return filePath.slice(prefix.length);
32
+ return null;
33
+ }
34
+
14
35
  export function getGitStatus(cwd: string, options: { includeIgnored?: boolean } = {}): Map<string, string> {
15
36
  const status = new Map<string, string>();
16
37
  try {
@@ -18,11 +39,13 @@ export function getGitStatus(cwd: string, options: { includeIgnored?: boolean }
18
39
  if (options.includeIgnored !== false) {
19
40
  flags.push("--ignored");
20
41
  }
42
+ const prefix = getGitPathPrefix(cwd);
21
43
  const output = execSync(`git status ${flags.join(" ")}`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
22
44
  for (const line of output.split("\n")) {
23
45
  if (line.length < 3) continue;
24
46
  const statusCode = line.slice(0, 2).trim() || "?";
25
- const filePath = line.slice(3);
47
+ const filePath = stripPathPrefix(line.slice(3), prefix);
48
+ if (filePath === null || !filePath) continue;
26
49
  status.set(filePath, statusCode);
27
50
  }
28
51
  } catch {}
@@ -39,6 +62,7 @@ export function getGitFileList(cwd: string): string[] {
39
62
  } catch {}
40
63
 
41
64
  try {
65
+ const prefix = getGitPathPrefix(cwd);
42
66
  const statusOutput = execSync("git status --porcelain -uall -z", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
43
67
  const entries = statusOutput.split("\0");
44
68
  for (let i = 0; i < entries.length; i++) {
@@ -52,8 +76,9 @@ export function getGitFileList(cwd: string): string[] {
52
76
  } else if (filePath.includes(" -> ")) {
53
77
  filePath = filePath.split(" -> ").pop() || filePath;
54
78
  }
55
- if (filePath) {
56
- files.add(filePath);
79
+ const relPath = filePath ? stripPathPrefix(filePath, prefix) : null;
80
+ if (relPath) {
81
+ files.add(relPath);
57
82
  }
58
83
  }
59
84
  } catch {}
@@ -72,8 +97,10 @@ export function getGitBranch(cwd: string): string {
72
97
  export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
73
98
  const stats = new Map<string, DiffStats>();
74
99
  try {
75
- // Get diff stats for modified files
76
- const output = execSync("git diff --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
100
+ // Get diff stats for modified files. --relative keeps paths relative to cwd
101
+ // (and scoped to it) so they match the widget's cwd-relative node keys even
102
+ // when cwd is a subdirectory of the repository.
103
+ const output = execSync("git diff --relative --numstat HEAD", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
77
104
  for (const line of output.split("\n")) {
78
105
  const parts = line.split("\t");
79
106
  if (parts.length >= 3) {
@@ -84,7 +111,7 @@ export function getGitDiffStats(cwd: string): Map<string, DiffStats> {
84
111
  }
85
112
  }
86
113
  // Also get stats for staged files
87
- const stagedOutput = execSync("git diff --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
114
+ const stagedOutput = execSync("git diff --relative --numstat --cached", { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" });
88
115
  for (const line of stagedOutput.split("\n")) {
89
116
  const parts = line.split("\t");
90
117
  if (parts.length >= 3) {
package/index.ts CHANGED
@@ -5,14 +5,37 @@
5
5
  * Use /readfiles to open the file browser, navigate with j/k, Enter to view.
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
- import { join } from "node:path";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, join, resolve } from "node:path";
10
12
 
11
13
  import { createFileBrowser } from "./browser";
12
14
  import { POLL_INTERVAL_MS } from "./constants";
13
15
  import { formatCommentMessage } from "./comment";
14
16
  import { hasCommand } from "./utils";
15
17
 
18
+ function resolveInitialPath(arg: string | undefined, cwd: string): { path: string; error?: string } {
19
+ if (!arg) return { path: cwd };
20
+ let candidate = arg.trim();
21
+ if (!candidate) return { path: cwd };
22
+ const home = homedir();
23
+ if (candidate === "~") {
24
+ candidate = home;
25
+ } else if (candidate.startsWith("~/")) {
26
+ candidate = join(home, candidate.slice(2));
27
+ }
28
+ const absolute = isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
29
+ try {
30
+ if (!statSync(absolute).isDirectory()) {
31
+ return { path: cwd, error: `${absolute} is not a directory` };
32
+ }
33
+ } catch {
34
+ return { path: cwd, error: `${absolute} is not accessible` };
35
+ }
36
+ return { path: absolute };
37
+ }
38
+
16
39
  export default function editorExtension(pi: ExtensionAPI): void {
17
40
  const cwd = process.cwd();
18
41
  const agentModifiedFiles = new Set<string>();
@@ -20,14 +43,21 @@ export default function editorExtension(pi: ExtensionAPI): void {
20
43
  const getMissingDeps = () => requiredDeps.filter((dep) => !hasCommand(dep));
21
44
 
22
45
  pi.registerCommand("readfiles", {
23
- description: "Open file browser",
24
- handler: async (_args, ctx) => {
46
+ description: "Open file browser (optional: /readfiles <path> to start outside the current directory)",
47
+ handler: async (args, ctx) => {
25
48
  const missing = getMissingDeps();
26
49
  if (missing.length > 0) {
27
50
  ctx.ui.notify(`files-widget requires ${missing.join(", ")}. Install: brew install bat git-delta glow`, "error");
28
51
  return;
29
52
  }
30
53
 
54
+ const resolved = resolveInitialPath(args, cwd);
55
+ if (resolved.error) {
56
+ ctx.ui.notify(resolved.error, "error");
57
+ return;
58
+ }
59
+ const initialPath = resolved.path;
60
+
31
61
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
32
62
  let pollInterval: ReturnType<typeof setInterval> | null = null;
33
63
 
@@ -51,7 +81,15 @@ export default function editorExtension(pi: ExtensionAPI): void {
51
81
  };
52
82
 
53
83
  const requestRender = () => tui.requestRender();
54
- const browser = createFileBrowser(cwd, agentModifiedFiles, theme, cleanup, requestComment, requestRender);
84
+ const browser = createFileBrowser(
85
+ initialPath,
86
+ agentModifiedFiles,
87
+ theme,
88
+ cleanup,
89
+ requestComment,
90
+ requestRender,
91
+ cwd
92
+ );
55
93
 
56
94
  pollInterval = setInterval(() => {
57
95
  requestRender();
package/input-utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { decodeKittyPrintable } from "@mariozechner/pi-tui";
1
+ import { decodeKittyPrintable } from "@earendil-works/pi-tui";
2
2
 
3
3
  const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
4
4
  const BRACKETED_PASTE_START = "\u001b[200~";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.20",
3
+ "version": "0.2.0",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -15,8 +15,8 @@
15
15
  "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
16
  "homepage": "https://github.com/tmustier/pi-extensions/tree/main/files-widget",
17
17
  "peerDependencies": {
18
- "@mariozechner/pi-coding-agent": "*",
19
- "@mariozechner/pi-tui": "*"
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*"
20
20
  },
21
21
  "scripts": {
22
22
  "postinstall": "node -e \"console.log('pi-files-widget: required deps: bat, delta, glow. Install with: brew install bat git-delta glow')\""
@@ -26,5 +26,9 @@
26
26
  "index.ts"
27
27
  ],
28
28
  "video": "https://raw.githubusercontent.com/tmustier/pi-extensions/main/files-widget/demo.mp4"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-coding-agent": "^0.80.3",
32
+ "@earendil-works/pi-tui": "^0.80.3"
29
33
  }
30
34
  }
package/utils.ts CHANGED
@@ -2,7 +2,8 @@ import { execSync } from "node:child_process";
2
2
 
3
3
  export function hasCommand(cmd: string): boolean {
4
4
  try {
5
- execSync(`which ${cmd}`, { stdio: "ignore" });
5
+ const checkCmd = process.platform === "win32" ? "where" : "which";
6
+ execSync(`${checkCmd} ${cmd}`, { stdio: "ignore" });
6
7
  return true;
7
8
  } catch {
8
9
  return false;
package/viewer.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { Theme } from "@mariozechner/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { relative } from "node:path";
5
5
 
@@ -59,11 +59,17 @@ export interface ViewerController {
59
59
  handleInput(data: string): ViewerAction;
60
60
  }
61
61
 
62
+ export interface ViewerConfig {
63
+ getRoot: () => string;
64
+ projectCwd: string;
65
+ }
66
+
62
67
  export function createViewer(
63
- cwd: string,
68
+ config: ViewerConfig,
64
69
  theme: Theme,
65
70
  requestComment: (payload: CommentPayload, comment: string) => void
66
71
  ): ViewerController {
72
+ const { getRoot, projectCwd } = config;
67
73
  const searchInput = createTextInputBuffer();
68
74
  const commentInput = createTextInputBuffer({ preserveNewlines: true });
69
75
 
@@ -177,7 +183,7 @@ export function createViewer(
177
183
  if (!state.file) return;
178
184
  refreshRawContent();
179
185
  const hasChanges = !!state.file.gitStatus;
180
- const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown);
186
+ const result = loadFileContent(state.file.path, getRoot(), state.diffMode, hasChanges, width, state.renderMarkdown);
181
187
  state.content = result.lines;
182
188
  state.renderMarkdown = result.renderedMarkdown;
183
189
  state.lastRenderWidth = width;
@@ -242,7 +248,8 @@ export function createViewer(
242
248
 
243
249
  const rawLines = state.rawContent.split("\n");
244
250
  const selectedText = rawLines.slice(state.selectStart, state.selectEnd + 1).join("\n");
245
- const relPath = relative(cwd, state.file.path);
251
+ const rel = relative(projectCwd, state.file.path);
252
+ const relPath = !rel || rel.startsWith("..") ? state.file.path : rel;
246
253
  const lineRange = state.selectStart === state.selectEnd
247
254
  ? `line ${state.selectStart + 1}`
248
255
  : `lines ${state.selectStart + 1}-${state.selectEnd + 1}`;