@firstpick/pi-package-webui 0.3.6 → 0.3.7

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/bin/pi-webui.mjs CHANGED
@@ -2754,6 +2754,8 @@ async function getWorkspaceInfo(cwd, startedAt) {
2754
2754
  }
2755
2755
 
2756
2756
  let activeGitWorkflowProcess = null;
2757
+ const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
2758
+ const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
2757
2759
 
2758
2760
  async function getGitRoot(cwd) {
2759
2761
  const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
@@ -2763,6 +2765,120 @@ async function getGitRoot(cwd) {
2763
2765
  return path.resolve(result.stdout.trim());
2764
2766
  }
2765
2767
 
2768
+ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_TIMEOUT_MS, maxOutputLength = GIT_CHANGES_DIFF_MAX_OUTPUT } = {}) {
2769
+ const result = await runCommand("git", args, { cwd: root, timeoutMs, maxOutputLength });
2770
+ if (result.exitCode === 0 && !result.timedOut && !result.error) return result.stdout;
2771
+ const command = formatGitCommand(args);
2772
+ const message = result.timedOut
2773
+ ? `${command} timed out`
2774
+ : (result.stderr || result.stdout || result.error || `${command} failed with exit code ${result.exitCode ?? "unknown"}`);
2775
+ throw new Error(String(message).trim());
2776
+ }
2777
+
2778
+ function gitBranchFromStatus(statusText) {
2779
+ const branchLine = String(statusText || "").split(/\r?\n/).find((line) => line.startsWith("## ")) || "";
2780
+ return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
2781
+ }
2782
+
2783
+ function summarizeGitShortStatus(statusText) {
2784
+ const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
2785
+ for (const line of String(statusText || "").split(/\r?\n/)) {
2786
+ if (!line || line.startsWith("## ")) continue;
2787
+ const x = line[0] || " ";
2788
+ const y = line[1] || " ";
2789
+ if (x === "?" && y === "?") {
2790
+ summary.untracked += 1;
2791
+ continue;
2792
+ }
2793
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
2794
+ summary.conflicted += 1;
2795
+ continue;
2796
+ }
2797
+ if (x && x !== " ") summary.staged += 1;
2798
+ if (y && y !== " ") summary.unstaged += 1;
2799
+ }
2800
+ return summary;
2801
+ }
2802
+
2803
+ function resolveGitRelativePath(root, relativePath) {
2804
+ const normalized = String(relativePath || "").trim();
2805
+ if (!normalized || normalized.includes("\0")) throw new Error("Invalid git path");
2806
+ const resolved = path.resolve(root, normalized);
2807
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error(`Git path escapes repository: ${normalized}`);
2808
+ return resolved;
2809
+ }
2810
+
2811
+ function isLikelyBinaryBuffer(buffer) {
2812
+ return Buffer.isBuffer(buffer) && buffer.includes(0);
2813
+ }
2814
+
2815
+ function normalizeGitRelativePath(root, relativePath) {
2816
+ const resolved = resolveGitRelativePath(root, relativePath);
2817
+ return path.relative(root, resolved).split(path.sep).join("/");
2818
+ }
2819
+
2820
+ async function readGitUntrackedEntry(root, file) {
2821
+ const normalized = normalizeGitRelativePath(root, file);
2822
+ const filePath = resolveGitRelativePath(root, normalized);
2823
+ const info = await stat(filePath);
2824
+ if (!info.isFile()) return { path: normalized, size: info.size, binary: false, content: "", error: "Not a regular file" };
2825
+ const buffer = await readFile(filePath);
2826
+ const binary = isLikelyBinaryBuffer(buffer);
2827
+ return {
2828
+ path: normalized,
2829
+ size: info.size,
2830
+ binary,
2831
+ content: binary ? "" : buffer.toString("utf8"),
2832
+ };
2833
+ }
2834
+
2835
+ async function readGitUntrackedEntries(root, files) {
2836
+ const entries = [];
2837
+ for (const file of files) {
2838
+ try {
2839
+ entries.push(await readGitUntrackedEntry(root, file));
2840
+ } catch (error) {
2841
+ entries.push({ path: file, size: 0, binary: false, content: "", error: sanitizeError(error) });
2842
+ }
2843
+ }
2844
+ return entries;
2845
+ }
2846
+
2847
+ async function readGitUntrackedFile(cwd, requestedPath) {
2848
+ const root = await getGitRoot(cwd);
2849
+ const normalized = normalizeGitRelativePath(root, requestedPath);
2850
+ const listed = await runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard", "--", normalized], { maxOutputLength: 120_000 });
2851
+ const files = listed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2852
+ if (!files.includes(normalized)) throw new Error(`Not an untracked file: ${normalized}`);
2853
+ return readGitUntrackedEntry(root, normalized);
2854
+ }
2855
+
2856
+ async function readGitChanges(cwd) {
2857
+ const root = await getGitRoot(cwd);
2858
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
2859
+ const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
2860
+ runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
2861
+ runGitReadCommand(root, diffArgs),
2862
+ runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
2863
+ runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
2864
+ ]);
2865
+ const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2866
+ const untracked = await readGitUntrackedEntries(root, untrackedFiles);
2867
+ return {
2868
+ cwd,
2869
+ root,
2870
+ branch: gitBranchFromStatus(statusText),
2871
+ generatedAt: new Date().toISOString(),
2872
+ summary: summarizeGitShortStatus(statusText),
2873
+ status: statusText.trimEnd(),
2874
+ sections: [
2875
+ { key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
2876
+ { key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
2877
+ ],
2878
+ untracked,
2879
+ };
2880
+ }
2881
+
2766
2882
  function commitMessagePaths(root) {
2767
2883
  return {
2768
2884
  shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
@@ -2829,6 +2945,68 @@ async function currentGitBranch(root) {
2829
2945
  return branch;
2830
2946
  }
2831
2947
 
2948
+ async function currentGitBranchForPicker(root) {
2949
+ try {
2950
+ return (await runGitReadCommand(root, ["branch", "--show-current"], { timeoutMs: 5000, maxOutputLength: 10_000 })).trim();
2951
+ } catch {
2952
+ return "";
2953
+ }
2954
+ }
2955
+
2956
+ function normalizeGitBranchList(branchText, current = "") {
2957
+ const seen = new Set();
2958
+ const branches = [];
2959
+ for (const line of String(branchText || "").split(/\r?\n/)) {
2960
+ const name = line.trim();
2961
+ if (!name || seen.has(name)) continue;
2962
+ seen.add(name);
2963
+ branches.push({ name, current: !!current && name === current });
2964
+ }
2965
+ return branches.sort((left, right) => {
2966
+ if (left.current !== right.current) return left.current ? -1 : 1;
2967
+ return left.name.localeCompare(right.name);
2968
+ });
2969
+ }
2970
+
2971
+ async function readGitBranches(cwd) {
2972
+ const root = await getGitRoot(cwd);
2973
+ const [current, branchText] = await Promise.all([
2974
+ currentGitBranchForPicker(root),
2975
+ runGitReadCommand(root, ["branch", "--format=%(refname:short)"], { timeoutMs: 5000, maxOutputLength: 120_000 }),
2976
+ ]);
2977
+ return {
2978
+ cwd,
2979
+ root,
2980
+ current,
2981
+ generatedAt: new Date().toISOString(),
2982
+ branches: normalizeGitBranchList(branchText, current),
2983
+ };
2984
+ }
2985
+
2986
+ async function switchGitBranch(cwd, branch, { create = false } = {}) {
2987
+ const root = await getGitRoot(cwd);
2988
+ const targetBranch = cleanGitBranchName(branch);
2989
+ await validateGitBranchName(root, targetBranch);
2990
+ const branches = await readGitBranches(cwd);
2991
+ const branchExists = branches.branches.some((item) => item.name === targetBranch);
2992
+ if (create && branchExists) throw new Error(`Local git branch already exists: ${targetBranch}`);
2993
+ if (!create && !branchExists) throw new Error(`Unknown local git branch: ${targetBranch}`);
2994
+ if (!create && branches.current === targetBranch) {
2995
+ return { ok: true, data: { command: `git switch ${targetBranch}`, stdout: "", stderr: "", exitCode: 0, branch: targetBranch, root, switched: false, created: false } };
2996
+ }
2997
+ const args = create ? ["switch", "-c", targetBranch] : ["switch", targetBranch];
2998
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(args, { cwd: root, timeoutMs: 10 * 60 * 1000 }));
2999
+ if (payload.ok) {
3000
+ payload.data.branch = targetBranch;
3001
+ payload.data.root = root;
3002
+ payload.data.switched = true;
3003
+ payload.data.created = create;
3004
+ } else {
3005
+ payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || `Failed to ${create ? "create and switch to" : "switch to"} ${targetBranch}`).trim();
3006
+ }
3007
+ return payload;
3008
+ }
3009
+
2832
3010
  async function defaultGitRemote(root) {
2833
3011
  const result = await runGitWorkflowCommand(["remote"], { cwd: root, timeoutMs: 5000 });
2834
3012
  if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || "Cannot list git remotes").trim());
@@ -6097,6 +6275,47 @@ const server = createServer(async (req, res) => {
6097
6275
  return;
6098
6276
  }
6099
6277
 
6278
+ if (url.pathname === "/api/git-changes" && req.method === "GET") {
6279
+ const tab = getRequestedTab(req, url);
6280
+ try {
6281
+ sendJson(res, 200, { ok: true, data: await readGitChanges(tab.cwd) });
6282
+ } catch (error) {
6283
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6284
+ }
6285
+ return;
6286
+ }
6287
+
6288
+ if (url.pathname === "/api/git-changes/untracked-file" && req.method === "GET") {
6289
+ const tab = getRequestedTab(req, url);
6290
+ try {
6291
+ sendJson(res, 200, { ok: true, data: await readGitUntrackedFile(tab.cwd, url.searchParams.get("path") || "") });
6292
+ } catch (error) {
6293
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6294
+ }
6295
+ return;
6296
+ }
6297
+
6298
+ if (url.pathname === "/api/git-branches" && req.method === "GET") {
6299
+ const tab = getRequestedTab(req, url);
6300
+ try {
6301
+ sendJson(res, 200, { ok: true, data: await readGitBranches(tab.cwd) });
6302
+ } catch (error) {
6303
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6304
+ }
6305
+ return;
6306
+ }
6307
+
6308
+ if (url.pathname === "/api/git-branch" && req.method === "POST") {
6309
+ const body = await readJsonBody(req);
6310
+ const tab = getRequestedTab(req, url, body);
6311
+ try {
6312
+ sendJson(res, 200, await switchGitBranch(tab.cwd, body.branch, { create: body.create === true }));
6313
+ } catch (error) {
6314
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
6315
+ }
6316
+ return;
6317
+ }
6318
+
6100
6319
  if (url.pathname.startsWith("/api/git-workflow/")) {
6101
6320
  const body = req.method === "POST" ? await readJsonBody(req) : {};
6102
6321
  const tab = getRequestedTab(req, url, body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",