@firstpick/pi-package-webui 0.4.6 → 0.4.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 +151 -22
- package/package.json +10 -3
- package/public/app.js +142 -24
- package/public/index.html +4 -1
- package/public/styles.css +39 -0
- package/tests/http-endpoints-harness.test.mjs +48 -0
- package/tests/mobile-static.test.mjs +9 -2
package/bin/pi-webui.mjs
CHANGED
|
@@ -640,7 +640,8 @@ function makeHttpError(statusCode, message) {
|
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
function sendError(res, statusCode, error) {
|
|
643
|
-
|
|
643
|
+
const message = statusCode >= 500 ? sanitizeError(error) : formatCliError(error);
|
|
644
|
+
sendJson(res, statusCode, { ok: false, error: message });
|
|
644
645
|
}
|
|
645
646
|
|
|
646
647
|
function formatBytes(bytes) {
|
|
@@ -1627,22 +1628,85 @@ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } =
|
|
|
1627
1628
|
return { id, label, command, path: filePath, args };
|
|
1628
1629
|
}
|
|
1629
1630
|
|
|
1630
|
-
|
|
1631
|
+
function customAppRunnerDiagnostic(severity, message, runner = {}) {
|
|
1632
|
+
const source = runner && typeof runner === "object" ? runner : {};
|
|
1633
|
+
return {
|
|
1634
|
+
severity,
|
|
1635
|
+
message,
|
|
1636
|
+
runnerId: source.id || "",
|
|
1637
|
+
runnerLabel: source.label || "",
|
|
1638
|
+
path: source.path || source.projectFile || "",
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function directCustomRunnerUnavailableReason(filePath, stats) {
|
|
1643
|
+
if (process.platform !== "win32" && stats && (stats.mode & 0o111) === 0) {
|
|
1644
|
+
return `Path is not executable: ${filePath}. Run chmod +x ${filePath} or set Command to bash, python3, node, etc.`;
|
|
1645
|
+
}
|
|
1646
|
+
return "";
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function customAppRunnerUnavailableReason(projectRoot, runner) {
|
|
1650
|
+
const filePath = runner.path;
|
|
1651
|
+
let stats;
|
|
1652
|
+
try {
|
|
1653
|
+
stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, filePath));
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
return `Cannot access path ${filePath}: ${formatCliError(error)}`;
|
|
1656
|
+
}
|
|
1657
|
+
if (!stats?.isFile()) return `Path to file does not exist: ${filePath}`;
|
|
1658
|
+
const command = cleanCustomRunnerCommand(runner.command);
|
|
1659
|
+
const directReason = command === "./" ? directCustomRunnerUnavailableReason(filePath, stats) : "";
|
|
1660
|
+
if (directReason) return directReason;
|
|
1661
|
+
const commandParts = customRunnerCommandParts(command);
|
|
1662
|
+
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return `Command is not available: ${commandParts[0]}`;
|
|
1663
|
+
return "";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async function readAppRunnerConfig(projectRoot, { strictRead = false } = {}) {
|
|
1631
1667
|
const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
|
|
1632
|
-
|
|
1633
|
-
const
|
|
1668
|
+
let source = {};
|
|
1669
|
+
const diagnostics = [];
|
|
1670
|
+
try {
|
|
1671
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
1672
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1673
|
+
source = parsed;
|
|
1674
|
+
} else {
|
|
1675
|
+
const message = `${APP_RUNNER_CONFIG_FILE} must contain a JSON object`;
|
|
1676
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1677
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1678
|
+
}
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
if (error?.code !== "ENOENT") {
|
|
1681
|
+
const message = `Cannot read ${APP_RUNNER_CONFIG_FILE}: ${formatCliError(error)}`;
|
|
1682
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1683
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1684
|
+
console.warn(`failed to read custom app runner config ${configPath}: ${sanitizeError(error)}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (source.runners !== undefined && !Array.isArray(source.runners)) {
|
|
1688
|
+
const message = `${APP_RUNNER_CONFIG_FILE} runners must be an array`;
|
|
1689
|
+
if (strictRead) throw makeHttpError(400, message);
|
|
1690
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message));
|
|
1691
|
+
}
|
|
1634
1692
|
const rawRunners = Array.isArray(source.runners) ? source.runners : [];
|
|
1635
1693
|
const runners = [];
|
|
1636
1694
|
for (const raw of rawRunners) {
|
|
1637
1695
|
try {
|
|
1638
1696
|
const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
|
|
1639
|
-
if (
|
|
1697
|
+
if (runners.some((item) => item.id === runner.id)) {
|
|
1698
|
+
diagnostics.push(customAppRunnerDiagnostic("warning", `Duplicate custom runner ignored: ${runner.label || runner.path || runner.id}`, runner));
|
|
1699
|
+
} else {
|
|
1700
|
+
runners.push(runner);
|
|
1701
|
+
}
|
|
1640
1702
|
} catch (error) {
|
|
1703
|
+
const message = `Invalid custom runner ignored: ${formatCliError(error)}`;
|
|
1704
|
+
diagnostics.push(customAppRunnerDiagnostic("error", message, raw));
|
|
1641
1705
|
console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
|
|
1642
1706
|
}
|
|
1643
1707
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
|
|
1644
1708
|
}
|
|
1645
|
-
return { projectRoot, configPath, runners };
|
|
1709
|
+
return { projectRoot, configPath, runners, diagnostics };
|
|
1646
1710
|
}
|
|
1647
1711
|
|
|
1648
1712
|
async function writeAppRunnerConfig(projectRoot, runners) {
|
|
@@ -1660,15 +1724,12 @@ async function writeAppRunnerConfig(projectRoot, runners) {
|
|
|
1660
1724
|
|
|
1661
1725
|
async function customAppRunnerCandidate(projectRoot, configPath, runner) {
|
|
1662
1726
|
const filePath = runner.path;
|
|
1663
|
-
|
|
1664
|
-
const stats = await fileStatsIfExists(absolutePath);
|
|
1665
|
-
if (!stats?.isFile()) return null;
|
|
1727
|
+
if (await customAppRunnerUnavailableReason(projectRoot, runner)) return null;
|
|
1666
1728
|
const command = cleanCustomRunnerCommand(runner.command);
|
|
1667
1729
|
const args = parseCustomRunnerArgs(runner.args);
|
|
1668
1730
|
const commandParts = customRunnerCommandParts(command);
|
|
1669
1731
|
const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
|
|
1670
1732
|
const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
|
|
1671
|
-
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
|
|
1672
1733
|
return appRunnerCandidate({
|
|
1673
1734
|
id: appRunnerId("custom", runner.id),
|
|
1674
1735
|
label: runner.label || path.basename(filePath),
|
|
@@ -1696,24 +1757,32 @@ async function addCustomAppRunners(runners, cwd) {
|
|
|
1696
1757
|
async function getCustomAppRunnerConfigData(tab) {
|
|
1697
1758
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1698
1759
|
const config = await readAppRunnerConfig(projectRoot);
|
|
1760
|
+
const runners = [];
|
|
1761
|
+
for (const runner of config.runners) {
|
|
1762
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, runner);
|
|
1763
|
+
runners.push({
|
|
1764
|
+
...publicCustomRunnerDefinition(runner),
|
|
1765
|
+
available: !unavailableReason,
|
|
1766
|
+
unavailableReason,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1699
1769
|
return {
|
|
1700
1770
|
projectRoot,
|
|
1701
1771
|
displayProjectRoot: displayPath(projectRoot),
|
|
1702
1772
|
configFile: config.configPath,
|
|
1703
1773
|
displayConfigFile: displayPath(config.configPath),
|
|
1704
1774
|
relativeConfigFile: APP_RUNNER_CONFIG_FILE,
|
|
1705
|
-
runners
|
|
1775
|
+
runners,
|
|
1776
|
+
diagnostics: config.diagnostics,
|
|
1706
1777
|
};
|
|
1707
1778
|
}
|
|
1708
1779
|
|
|
1709
1780
|
async function saveCustomAppRunner(tab, rawRunner) {
|
|
1710
1781
|
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1711
|
-
const config = await readAppRunnerConfig(projectRoot);
|
|
1782
|
+
const config = await readAppRunnerConfig(projectRoot, { strictRead: true });
|
|
1712
1783
|
const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
|
|
1713
|
-
const
|
|
1714
|
-
if (
|
|
1715
|
-
const commandParts = customRunnerCommandParts(normalized.command);
|
|
1716
|
-
if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
|
|
1784
|
+
const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, normalized);
|
|
1785
|
+
if (unavailableReason) throw makeHttpError(400, unavailableReason);
|
|
1717
1786
|
const runners = config.runners.filter((runner) => runner.id !== normalized.id);
|
|
1718
1787
|
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
|
|
1719
1788
|
runners.push(normalized);
|
|
@@ -3066,6 +3135,7 @@ async function getWorkspaceInfo(cwd, startedAt) {
|
|
|
3066
3135
|
let activeGitWorkflowProcess = null;
|
|
3067
3136
|
const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
|
|
3068
3137
|
const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
|
|
3138
|
+
const GIT_PULL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
3069
3139
|
|
|
3070
3140
|
async function getGitRoot(cwd) {
|
|
3071
3141
|
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
@@ -3176,15 +3246,61 @@ async function readGitUntrackedFile(cwd, requestedPath) {
|
|
|
3176
3246
|
return readGitUntrackedEntry(root, normalized);
|
|
3177
3247
|
}
|
|
3178
3248
|
|
|
3249
|
+
async function gitUpstreamRef(root) {
|
|
3250
|
+
try {
|
|
3251
|
+
const upstream = await runGitReadCommand(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { timeoutMs: 5000, maxOutputLength: 10_000 });
|
|
3252
|
+
return upstream.trim();
|
|
3253
|
+
} catch {
|
|
3254
|
+
return "";
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
async function readGitIncomingChanges(root, summary) {
|
|
3259
|
+
const upstream = await gitUpstreamRef(root);
|
|
3260
|
+
const remote = {
|
|
3261
|
+
upstream,
|
|
3262
|
+
behind: Number(summary?.behind || 0) || 0,
|
|
3263
|
+
canPull: !!upstream && (Number(summary?.behind || 0) || 0) > 0,
|
|
3264
|
+
};
|
|
3265
|
+
if (!remote.canPull) return { remote, section: null };
|
|
3266
|
+
|
|
3267
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", "HEAD..@{upstream}"];
|
|
3268
|
+
try {
|
|
3269
|
+
const diff = await runGitReadCommand(root, diffArgs);
|
|
3270
|
+
return {
|
|
3271
|
+
remote,
|
|
3272
|
+
section: {
|
|
3273
|
+
key: "incoming",
|
|
3274
|
+
label: `Incoming from ${upstream}`,
|
|
3275
|
+
command: `git diff --unified=0 HEAD..${upstream}`,
|
|
3276
|
+
diff: diff.trimEnd(),
|
|
3277
|
+
},
|
|
3278
|
+
};
|
|
3279
|
+
} catch (error) {
|
|
3280
|
+
return { remote: { ...remote, error: sanitizeError(error) }, section: null };
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
async function pullGitChanges(cwd) {
|
|
3285
|
+
const root = await getGitRoot(cwd);
|
|
3286
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["pull", "--ff-only"], { cwd: root, timeoutMs: GIT_PULL_TIMEOUT_MS }));
|
|
3287
|
+
if (payload.data) payload.data.root = root;
|
|
3288
|
+
if (payload.ok) payload.data.changes = await readGitChanges(root);
|
|
3289
|
+
else payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || "git pull --ff-only failed").trim();
|
|
3290
|
+
return payload;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3179
3293
|
async function readGitChanges(cwd) {
|
|
3180
3294
|
const root = await getGitRoot(cwd);
|
|
3181
|
-
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3295
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
3182
3296
|
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
3183
3297
|
runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
3184
3298
|
runGitReadCommand(root, diffArgs),
|
|
3185
|
-
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3299
|
+
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
3186
3300
|
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
3187
3301
|
]);
|
|
3302
|
+
const summary = summarizeGitShortStatus(statusText);
|
|
3303
|
+
const incoming = await readGitIncomingChanges(root, summary);
|
|
3188
3304
|
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3189
3305
|
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
3190
3306
|
return {
|
|
@@ -3192,12 +3308,14 @@ async function readGitChanges(cwd) {
|
|
|
3192
3308
|
root,
|
|
3193
3309
|
branch: gitBranchFromStatus(statusText),
|
|
3194
3310
|
generatedAt: new Date().toISOString(),
|
|
3195
|
-
summary
|
|
3311
|
+
summary,
|
|
3312
|
+
remote: incoming.remote,
|
|
3196
3313
|
status: statusText.trimEnd(),
|
|
3197
3314
|
sections: [
|
|
3198
|
-
|
|
3199
|
-
{ key: "
|
|
3200
|
-
|
|
3315
|
+
incoming.section,
|
|
3316
|
+
{ key: "staged", label: "Staged", command: "git diff --cached --unified=0", diff: stagedDiff.trimEnd() },
|
|
3317
|
+
{ key: "unstaged", label: "Unstaged", command: "git diff --unified=0", diff: unstagedDiff.trimEnd() },
|
|
3318
|
+
].filter(Boolean),
|
|
3201
3319
|
untracked,
|
|
3202
3320
|
};
|
|
3203
3321
|
}
|
|
@@ -7572,6 +7690,17 @@ const server = createServer(async (req, res) => {
|
|
|
7572
7690
|
return;
|
|
7573
7691
|
}
|
|
7574
7692
|
|
|
7693
|
+
if (url.pathname === "/api/git-changes/pull" && req.method === "POST") {
|
|
7694
|
+
const body = await readJsonBody(req);
|
|
7695
|
+
const tab = getRequestedTab(req, url, body);
|
|
7696
|
+
try {
|
|
7697
|
+
sendJson(res, 200, await pullGitChanges(tab.cwd));
|
|
7698
|
+
} catch (error) {
|
|
7699
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
7700
|
+
}
|
|
7701
|
+
return;
|
|
7702
|
+
}
|
|
7703
|
+
|
|
7575
7704
|
if (url.pathname === "/api/git-branches" && req.method === "GET") {
|
|
7576
7705
|
const tab = getRequestedTab(req, url);
|
|
7577
7706
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "node tests/run-all.mjs"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.79.
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.79.7"
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"@firstpick/pi-extension-btw": "^0.1.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@firstpick/pi-extension-todo-progress": "^0.2.4",
|
|
67
67
|
"@firstpick/pi-extension-tools": "^0.1.6",
|
|
68
68
|
"@firstpick/pi-extension-workflows": "^0.1.0",
|
|
69
|
-
"@firstpick/pi-package-remote-webui": "^0.1.
|
|
69
|
+
"@firstpick/pi-package-remote-webui": "^0.1.2",
|
|
70
70
|
"@firstpick/pi-prompts-git-pr": "^0.1.2",
|
|
71
71
|
"@firstpick/pi-themes-bundle": "^0.1.4"
|
|
72
72
|
},
|
|
@@ -84,5 +84,12 @@
|
|
|
84
84
|
],
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=22.19.0"
|
|
87
|
+
},
|
|
88
|
+
"overrides": {
|
|
89
|
+
"@earendil-works/pi-coding-agent": {
|
|
90
|
+
"protobufjs": "7.6.3",
|
|
91
|
+
"undici": "8.5.0",
|
|
92
|
+
"ws": "8.21.0"
|
|
93
|
+
}
|
|
87
94
|
}
|
|
88
95
|
}
|
package/public/app.js
CHANGED
|
@@ -114,6 +114,7 @@ const elements = {
|
|
|
114
114
|
gitChangesStatus: $("#gitChangesStatus"),
|
|
115
115
|
gitChangesBody: $("#gitChangesBody"),
|
|
116
116
|
gitChangesRefreshButton: $("#gitChangesRefreshButton"),
|
|
117
|
+
gitChangesPullButton: $("#gitChangesPullButton"),
|
|
117
118
|
gitChangesCloseButton: $("#gitChangesCloseButton"),
|
|
118
119
|
modelSelect: $("#modelSelect"),
|
|
119
120
|
setModelButton: $("#setModelButton"),
|
|
@@ -265,7 +266,7 @@ let foregroundReconcileTimer = null;
|
|
|
265
266
|
let eventSource = null;
|
|
266
267
|
let activeDialog = null;
|
|
267
268
|
let activeGitPrDialogResolve = null;
|
|
268
|
-
let gitChangesState = { loading: false, error: "", data: null, tabId: null };
|
|
269
|
+
let gitChangesState = { loading: false, pulling: false, error: "", message: "", data: null, tabId: null };
|
|
269
270
|
let gitChangesRequestSerial = 0;
|
|
270
271
|
const gitChangesUntrackedContentRequests = new Set();
|
|
271
272
|
let nativeCommandTabId = null;
|
|
@@ -285,6 +286,7 @@ let appRunnerMenuOpen = false;
|
|
|
285
286
|
let busyPromptBehaviorMenuOpen = false;
|
|
286
287
|
const skillUsageByTab = new Map();
|
|
287
288
|
let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
|
|
289
|
+
let appRunnerCustomFeedback = { type: "", message: "" };
|
|
288
290
|
let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
|
|
289
291
|
let optionsMenuOpen = false;
|
|
290
292
|
let availableCommands = [];
|
|
@@ -6382,13 +6384,23 @@ function renderGitChangesOverview(data) {
|
|
|
6382
6384
|
return overview;
|
|
6383
6385
|
}
|
|
6384
6386
|
|
|
6387
|
+
function gitDiffDisplayLine(row, side) {
|
|
6388
|
+
const type = row.type || "context";
|
|
6389
|
+
if (side === "old") {
|
|
6390
|
+
const text = row.left ?? "";
|
|
6391
|
+
return row.oldNumber !== "" && (type === "removed" || type === "changed") ? `-${text}` : text;
|
|
6392
|
+
}
|
|
6393
|
+
const text = row.right ?? "";
|
|
6394
|
+
return row.newNumber !== "" && (type === "added" || type === "changed") ? `+${text}` : text;
|
|
6395
|
+
}
|
|
6396
|
+
|
|
6385
6397
|
function renderGitDiffRow(row) {
|
|
6386
6398
|
const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
|
|
6387
6399
|
node.append(
|
|
6388
6400
|
make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
|
|
6389
|
-
make("code", "git-diff-line old", row
|
|
6401
|
+
make("code", "git-diff-line old", gitDiffDisplayLine(row, "old")),
|
|
6390
6402
|
make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
|
|
6391
|
-
make("code", "git-diff-line new", row
|
|
6403
|
+
make("code", "git-diff-line new", gitDiffDisplayLine(row, "new")),
|
|
6392
6404
|
);
|
|
6393
6405
|
return node;
|
|
6394
6406
|
}
|
|
@@ -6605,16 +6617,28 @@ function gitChangesGeneratedLabel(data) {
|
|
|
6605
6617
|
|
|
6606
6618
|
function renderGitChangesDialog() {
|
|
6607
6619
|
if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
|
|
6608
|
-
const { loading, error, data } = gitChangesState;
|
|
6609
|
-
|
|
6610
|
-
|
|
6620
|
+
const { loading, pulling, error, message, data } = gitChangesState;
|
|
6621
|
+
const behind = Number(data?.remote?.behind ?? data?.summary?.behind ?? 0) || 0;
|
|
6622
|
+
const canPull = behind > 0 && data?.remote?.canPull !== false;
|
|
6623
|
+
const remoteNotice = !error && data?.remote?.error ? `Incoming diff unavailable: ${data.remote.error}` : "";
|
|
6624
|
+
if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Git Changes";
|
|
6625
|
+
if (elements.gitChangesSubtitle) {
|
|
6626
|
+
const base = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
|
|
6627
|
+
elements.gitChangesSubtitle.textContent = data?.remote?.upstream ? `${base} · upstream ${data.remote.upstream}` : base;
|
|
6628
|
+
}
|
|
6611
6629
|
if (elements.gitChangesRefreshButton) {
|
|
6612
|
-
elements.gitChangesRefreshButton.disabled = loading;
|
|
6630
|
+
elements.gitChangesRefreshButton.disabled = loading || pulling;
|
|
6613
6631
|
elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
|
|
6614
6632
|
}
|
|
6633
|
+
if (elements.gitChangesPullButton) {
|
|
6634
|
+
elements.gitChangesPullButton.disabled = loading || pulling || !canPull;
|
|
6635
|
+
elements.gitChangesPullButton.textContent = pulling ? "Pulling…" : behind > 0 ? `Pull ↓${behind}` : "Pull";
|
|
6636
|
+
elements.gitChangesPullButton.title = canPull ? "Run git pull --ff-only for the current repository" : "No remote commits to pull";
|
|
6637
|
+
}
|
|
6615
6638
|
if (elements.gitChangesStatus) {
|
|
6616
|
-
|
|
6617
|
-
elements.gitChangesStatus.
|
|
6639
|
+
const statusText = error || (pulling ? "Pulling changes…" : loading ? "Loading git diff…" : message || remoteNotice || (data ? gitChangesGeneratedLabel(data) : ""));
|
|
6640
|
+
elements.gitChangesStatus.className = `git-changes-status ${error || remoteNotice ? "error" : message ? "success" : "muted"}`;
|
|
6641
|
+
elements.gitChangesStatus.textContent = statusText;
|
|
6618
6642
|
elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
|
|
6619
6643
|
}
|
|
6620
6644
|
|
|
@@ -6624,7 +6648,7 @@ function renderGitChangesDialog() {
|
|
|
6624
6648
|
body.append(make("div", "git-changes-empty", "Loading git diff…"));
|
|
6625
6649
|
return;
|
|
6626
6650
|
}
|
|
6627
|
-
if (error) {
|
|
6651
|
+
if (error && !data) {
|
|
6628
6652
|
body.append(make("div", "git-changes-empty error", error));
|
|
6629
6653
|
return;
|
|
6630
6654
|
}
|
|
@@ -6642,20 +6666,23 @@ function renderGitChangesDialog() {
|
|
|
6642
6666
|
if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
|
|
6643
6667
|
for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
|
|
6644
6668
|
if (untracked.length) body.append(renderGitUntrackedSection(untracked));
|
|
6645
|
-
if (!hasVisibleFiles)
|
|
6669
|
+
if (!hasVisibleFiles) {
|
|
6670
|
+
const emptyMessage = behind > 0 ? "No textual incoming diff was available for the remote commits." : "Working tree is clean. No staged, unstaged, untracked, or incoming diff.";
|
|
6671
|
+
body.append(make("div", "git-changes-empty success", emptyMessage));
|
|
6672
|
+
}
|
|
6646
6673
|
if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
|
|
6647
6674
|
}
|
|
6648
6675
|
|
|
6649
6676
|
async function loadGitChangesDialog(tabContext = activeTabContext()) {
|
|
6650
6677
|
const requestSerial = ++gitChangesRequestSerial;
|
|
6651
6678
|
gitChangesUntrackedContentRequests.clear();
|
|
6652
|
-
gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
|
|
6679
|
+
gitChangesState = { ...gitChangesState, loading: true, error: "", message: "", tabId: tabContext.tabId || activeTabId };
|
|
6653
6680
|
renderGitChangesDialog();
|
|
6654
6681
|
try {
|
|
6655
6682
|
const response = await api("/api/git-changes", { tabId: tabContext.tabId });
|
|
6656
6683
|
if (requestSerial !== gitChangesRequestSerial) return;
|
|
6657
6684
|
if (!response.ok) throw new Error(response.error || "Failed to load git changes");
|
|
6658
|
-
gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
|
|
6685
|
+
gitChangesState = { loading: false, pulling: false, error: "", message: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
|
|
6659
6686
|
} catch (error) {
|
|
6660
6687
|
if (requestSerial !== gitChangesRequestSerial) return;
|
|
6661
6688
|
gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
|
|
@@ -6668,7 +6695,7 @@ function openGitChangesDialog() {
|
|
|
6668
6695
|
hideFooterTooltip();
|
|
6669
6696
|
const tabContext = activeTabContext();
|
|
6670
6697
|
const tabId = tabContext.tabId || activeTabId;
|
|
6671
|
-
gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
|
|
6698
|
+
gitChangesState = { loading: true, pulling: false, error: "", message: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
|
|
6672
6699
|
renderGitChangesDialog();
|
|
6673
6700
|
if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
|
|
6674
6701
|
loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
|
|
@@ -6679,10 +6706,46 @@ function refreshGitChangesDialog() {
|
|
|
6679
6706
|
loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
|
|
6680
6707
|
}
|
|
6681
6708
|
|
|
6709
|
+
async function pullGitChangesDialog() {
|
|
6710
|
+
const tabContext = { tabId: gitChangesState.tabId || activeTabId };
|
|
6711
|
+
const behind = Number(gitChangesState.data?.remote?.behind ?? gitChangesState.data?.summary?.behind ?? 0) || 0;
|
|
6712
|
+
if (behind <= 0 || gitChangesState.pulling || gitChangesState.loading) return;
|
|
6713
|
+
const root = gitChangesState.data?.root || "the current repository";
|
|
6714
|
+
if (!window.confirm(`Run git pull --ff-only in ${root}?`)) return;
|
|
6715
|
+
|
|
6716
|
+
const requestSerial = ++gitChangesRequestSerial;
|
|
6717
|
+
gitChangesState = { ...gitChangesState, pulling: true, loading: false, error: "", message: "", tabId: tabContext.tabId };
|
|
6718
|
+
renderGitChangesDialog();
|
|
6719
|
+
try {
|
|
6720
|
+
const response = await api("/api/git-changes/pull", { method: "POST", body: {}, tabId: tabContext.tabId });
|
|
6721
|
+
if (requestSerial !== gitChangesRequestSerial) return;
|
|
6722
|
+
if (!response.ok) {
|
|
6723
|
+
const detail = [response.error, response.data?.stderr || response.data?.stdout].filter(Boolean).join("\n").trim();
|
|
6724
|
+
throw new Error(detail || "Failed to pull git changes");
|
|
6725
|
+
}
|
|
6726
|
+
const output = String(response.data?.stdout || response.data?.stderr || "").trim();
|
|
6727
|
+
gitChangesState = {
|
|
6728
|
+
loading: false,
|
|
6729
|
+
pulling: false,
|
|
6730
|
+
error: "",
|
|
6731
|
+
message: output || "Pulled remote changes successfully.",
|
|
6732
|
+
data: response.data?.changes || gitChangesState.data,
|
|
6733
|
+
tabId: tabContext.tabId,
|
|
6734
|
+
};
|
|
6735
|
+
addEvent("Pulled remote git changes.", "success");
|
|
6736
|
+
requestGitFooterWebuiPayload(tabContext, { force: true });
|
|
6737
|
+
} catch (error) {
|
|
6738
|
+
if (requestSerial !== gitChangesRequestSerial) return;
|
|
6739
|
+
gitChangesState = { ...gitChangesState, pulling: false, error: error.message || String(error) };
|
|
6740
|
+
addEvent(error.message || String(error), "error");
|
|
6741
|
+
}
|
|
6742
|
+
renderGitChangesDialog();
|
|
6743
|
+
}
|
|
6744
|
+
|
|
6682
6745
|
function closeGitChangesDialog() {
|
|
6683
6746
|
gitChangesRequestSerial += 1;
|
|
6684
6747
|
gitChangesUntrackedContentRequests.clear();
|
|
6685
|
-
gitChangesState = { ...gitChangesState, loading: false };
|
|
6748
|
+
gitChangesState = { ...gitChangesState, loading: false, pulling: false };
|
|
6686
6749
|
if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
|
|
6687
6750
|
}
|
|
6688
6751
|
|
|
@@ -8477,6 +8540,29 @@ async function refreshAppRunners(tabContext = activeTabContext()) {
|
|
|
8477
8540
|
renderWidgets();
|
|
8478
8541
|
}
|
|
8479
8542
|
|
|
8543
|
+
function appRunnerFailureState(runnerId, error, data = activeAppRunnerData()) {
|
|
8544
|
+
const runners = Array.isArray(data.runners) ? data.runners : [];
|
|
8545
|
+
const runner = runners.find((item) => item.id === runnerId) || {};
|
|
8546
|
+
const message = cleanStatusText(error?.message || String(error) || "Unknown app runner error");
|
|
8547
|
+
const command = runner.displayCommand || runner.shortDisplayCommand || runner.label || runnerId || "app runner";
|
|
8548
|
+
const timestamp = new Date().toISOString();
|
|
8549
|
+
return {
|
|
8550
|
+
id: `start-error:${Date.now()}`,
|
|
8551
|
+
runnerId,
|
|
8552
|
+
kind: runner.kind || "custom",
|
|
8553
|
+
label: runner.label || "App runner failed",
|
|
8554
|
+
command: runner.command || "",
|
|
8555
|
+
args: Array.isArray(runner.args) ? runner.args : [],
|
|
8556
|
+
displayCommand: command,
|
|
8557
|
+
cwd: data.cwd || "",
|
|
8558
|
+
status: "error",
|
|
8559
|
+
startedAt: timestamp,
|
|
8560
|
+
endedAt: timestamp,
|
|
8561
|
+
lineCount: 3,
|
|
8562
|
+
lines: [`$ ${command}`, "# failed to start app runner", `# ${message}`],
|
|
8563
|
+
};
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8480
8566
|
async function runAppRunner(runnerId) {
|
|
8481
8567
|
const tabContext = activeTabContext();
|
|
8482
8568
|
if (!tabContext.tabId || !runnerId) return;
|
|
@@ -8491,7 +8577,12 @@ async function runAppRunner(runnerId) {
|
|
|
8491
8577
|
const command = response.data?.activeRun?.displayCommand || "app runner";
|
|
8492
8578
|
addEvent(`started ${command}`, "info");
|
|
8493
8579
|
} catch (error) {
|
|
8494
|
-
if (isCurrentTabContext(tabContext))
|
|
8580
|
+
if (!isCurrentTabContext(tabContext)) return;
|
|
8581
|
+
const message = cleanStatusText(error.message || String(error));
|
|
8582
|
+
setAppRunnerData(tabContext.tabId, { activeRun: appRunnerFailureState(runnerId, error, activeAppRunnerData()) });
|
|
8583
|
+
renderAppRunnerControls();
|
|
8584
|
+
renderWidgets();
|
|
8585
|
+
addEvent(`app runner failed: ${message}`, "error");
|
|
8495
8586
|
}
|
|
8496
8587
|
}
|
|
8497
8588
|
|
|
@@ -8572,9 +8663,14 @@ function activeAppRunnerCustomConfig() {
|
|
|
8572
8663
|
return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
|
|
8573
8664
|
}
|
|
8574
8665
|
|
|
8575
|
-
function resetAppRunnerCustomDraft() {
|
|
8666
|
+
function resetAppRunnerCustomDraft({ clearFeedback = true } = {}) {
|
|
8576
8667
|
appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
|
|
8577
8668
|
appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
|
|
8669
|
+
if (clearFeedback) appRunnerCustomFeedback = { type: "", message: "" };
|
|
8670
|
+
}
|
|
8671
|
+
|
|
8672
|
+
function setAppRunnerCustomFeedback(type, message) {
|
|
8673
|
+
appRunnerCustomFeedback = { type, message: cleanStatusText(message || "") };
|
|
8578
8674
|
}
|
|
8579
8675
|
|
|
8580
8676
|
function appRunnerRelativeDir(filePath) {
|
|
@@ -8634,8 +8730,10 @@ async function saveAppRunnerCustomRunner(form) {
|
|
|
8634
8730
|
updateAppRunnerCustomDraftFrom(form);
|
|
8635
8731
|
const payload = appRunnerCustomDraftPayload();
|
|
8636
8732
|
if (!payload.path) {
|
|
8733
|
+
setAppRunnerCustomFeedback("warning", "Custom app runner path is required.");
|
|
8734
|
+
renderAppRunnerInfoDialog();
|
|
8735
|
+
requestAnimationFrame(() => document.querySelector("#appRunnerCustomPathInput")?.focus());
|
|
8637
8736
|
addEvent("custom app runner path is required", "warn");
|
|
8638
|
-
form?.querySelector("#appRunnerCustomPathInput")?.focus();
|
|
8639
8737
|
return;
|
|
8640
8738
|
}
|
|
8641
8739
|
const tabContext = activeTabContext();
|
|
@@ -8643,13 +8741,18 @@ async function saveAppRunnerCustomRunner(form) {
|
|
|
8643
8741
|
const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
|
|
8644
8742
|
if (!isCurrentTabContext(tabContext)) return;
|
|
8645
8743
|
setAppRunnerData(tabContext.tabId, response.data || {});
|
|
8646
|
-
resetAppRunnerCustomDraft();
|
|
8744
|
+
resetAppRunnerCustomDraft({ clearFeedback: false });
|
|
8745
|
+
setAppRunnerCustomFeedback("success", "Saved custom app runner. It should now appear in the Run menu when available.");
|
|
8647
8746
|
renderAppRunnerControls();
|
|
8648
8747
|
renderWidgets();
|
|
8649
8748
|
renderAppRunnerInfoDialog();
|
|
8650
8749
|
addEvent("saved custom app runner", "info");
|
|
8651
8750
|
} catch (error) {
|
|
8652
|
-
if (isCurrentTabContext(tabContext))
|
|
8751
|
+
if (!isCurrentTabContext(tabContext)) return;
|
|
8752
|
+
const message = error.message || String(error);
|
|
8753
|
+
setAppRunnerCustomFeedback("error", `Custom app runner was not saved: ${message}`);
|
|
8754
|
+
renderAppRunnerInfoDialog();
|
|
8755
|
+
addEvent(`custom app runner was not saved: ${message}`, "error");
|
|
8653
8756
|
}
|
|
8654
8757
|
}
|
|
8655
8758
|
|
|
@@ -8659,13 +8762,18 @@ async function deleteAppRunnerCustomRunner(id) {
|
|
|
8659
8762
|
const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
|
|
8660
8763
|
if (!isCurrentTabContext(tabContext)) return;
|
|
8661
8764
|
setAppRunnerData(tabContext.tabId, response.data || {});
|
|
8662
|
-
if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
|
|
8765
|
+
if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft({ clearFeedback: false });
|
|
8766
|
+
setAppRunnerCustomFeedback("success", "Deleted custom app runner.");
|
|
8663
8767
|
renderAppRunnerControls();
|
|
8664
8768
|
renderWidgets();
|
|
8665
8769
|
renderAppRunnerInfoDialog();
|
|
8666
8770
|
addEvent("deleted custom app runner", "warn");
|
|
8667
8771
|
} catch (error) {
|
|
8668
|
-
if (isCurrentTabContext(tabContext))
|
|
8772
|
+
if (!isCurrentTabContext(tabContext)) return;
|
|
8773
|
+
const message = error.message || String(error);
|
|
8774
|
+
setAppRunnerCustomFeedback("error", `Custom app runner was not deleted: ${message}`);
|
|
8775
|
+
renderAppRunnerInfoDialog();
|
|
8776
|
+
addEvent(`custom app runner was not deleted: ${message}`, "error");
|
|
8669
8777
|
}
|
|
8670
8778
|
}
|
|
8671
8779
|
|
|
@@ -8750,9 +8858,10 @@ function renderAppRunnerCustomSection() {
|
|
|
8750
8858
|
existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
|
|
8751
8859
|
} else {
|
|
8752
8860
|
for (const runner of customRunners) {
|
|
8753
|
-
const row = make("div",
|
|
8861
|
+
const row = make("div", `app-runner-custom-item${runner.available === false ? " unavailable" : ""}`);
|
|
8754
8862
|
const details = make("div", "app-runner-custom-item-details");
|
|
8755
8863
|
details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
|
|
8864
|
+
if (runner.unavailableReason) details.append(make("span", "app-runner-custom-warning", `Not available: ${runner.unavailableReason}`));
|
|
8756
8865
|
const actions = make("div", "app-runner-custom-item-actions");
|
|
8757
8866
|
const edit = make("button", "", "Edit");
|
|
8758
8867
|
edit.type = "button";
|
|
@@ -8774,6 +8883,13 @@ function renderAppRunnerCustomSection() {
|
|
|
8774
8883
|
}
|
|
8775
8884
|
section.append(existing);
|
|
8776
8885
|
|
|
8886
|
+
const diagnostics = Array.isArray(config.diagnostics) ? config.diagnostics.filter((item) => item?.message) : [];
|
|
8887
|
+
if (diagnostics.length) {
|
|
8888
|
+
const diagnosticList = make("div", "app-runner-custom-diagnostics");
|
|
8889
|
+
for (const item of diagnostics) diagnosticList.append(make("div", `app-runner-custom-feedback ${item.severity || "warning"}`, item.message));
|
|
8890
|
+
section.append(diagnosticList);
|
|
8891
|
+
}
|
|
8892
|
+
|
|
8777
8893
|
const form = make("div", "app-runner-custom-form");
|
|
8778
8894
|
const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
|
|
8779
8895
|
const commandField = appRunnerInputField({ id: "appRunnerCustomCommandInput", label: "Command", value: appRunnerCustomDraft.command || "./", placeholder: "./", hint: "Use ./ to execute the selected file directly, or use bash, python3, node, bun, uv run, etc." });
|
|
@@ -8798,6 +8914,7 @@ function renderAppRunnerCustomSection() {
|
|
|
8798
8914
|
reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
|
|
8799
8915
|
formActions.append(save, reset);
|
|
8800
8916
|
form.append(formActions);
|
|
8917
|
+
if (appRunnerCustomFeedback.message) form.append(make("div", `app-runner-custom-feedback ${appRunnerCustomFeedback.type || "info"}`, appRunnerCustomFeedback.message));
|
|
8801
8918
|
const browser = renderAppRunnerFileBrowser();
|
|
8802
8919
|
if (browser) form.append(browser);
|
|
8803
8920
|
section.append(form);
|
|
@@ -18713,6 +18830,7 @@ window.addEventListener("blur", () => {
|
|
|
18713
18830
|
});
|
|
18714
18831
|
|
|
18715
18832
|
elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
|
|
18833
|
+
elements.gitChangesPullButton?.addEventListener("click", () => pullGitChangesDialog().catch((error) => addEvent(error.message || String(error), "error")));
|
|
18716
18834
|
elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
|
|
18717
18835
|
elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
|
|
18718
18836
|
elements.gitChangesDialog?.addEventListener("cancel", (event) => {
|
|
@@ -18721,7 +18839,7 @@ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
|
|
|
18721
18839
|
});
|
|
18722
18840
|
elements.gitChangesDialog?.addEventListener("close", () => {
|
|
18723
18841
|
gitChangesRequestSerial += 1;
|
|
18724
|
-
gitChangesState = { ...gitChangesState, loading: false };
|
|
18842
|
+
gitChangesState = { ...gitChangesState, loading: false, pulling: false };
|
|
18725
18843
|
});
|
|
18726
18844
|
|
|
18727
18845
|
elements.refreshCodexUsageButton?.addEventListener("click", () => {
|
package/public/index.html
CHANGED
|
@@ -539,7 +539,10 @@
|
|
|
539
539
|
<h2 id="gitChangesTitle">Uncommitted Changes</h2>
|
|
540
540
|
<p id="gitChangesSubtitle" class="muted">Current tab git diff</p>
|
|
541
541
|
</div>
|
|
542
|
-
<
|
|
542
|
+
<div class="git-changes-actions">
|
|
543
|
+
<button id="gitChangesRefreshButton" type="button">Refresh</button>
|
|
544
|
+
<button id="gitChangesPullButton" class="primary" type="button" disabled>Pull</button>
|
|
545
|
+
</div>
|
|
543
546
|
</div>
|
|
544
547
|
<p id="gitChangesStatus" class="git-changes-status muted" role="status" aria-live="polite"></p>
|
|
545
548
|
<div id="gitChangesBody" class="git-changes-body"></div>
|
package/public/styles.css
CHANGED
|
@@ -2294,6 +2294,14 @@ button.footer-meta {
|
|
|
2294
2294
|
.git-changes-header > div:first-child {
|
|
2295
2295
|
min-width: 0;
|
|
2296
2296
|
}
|
|
2297
|
+
.git-changes-actions {
|
|
2298
|
+
flex: 0 0 auto;
|
|
2299
|
+
display: flex;
|
|
2300
|
+
align-items: center;
|
|
2301
|
+
justify-content: flex-end;
|
|
2302
|
+
gap: 0.5rem;
|
|
2303
|
+
flex-wrap: wrap;
|
|
2304
|
+
}
|
|
2297
2305
|
.git-changes-kicker {
|
|
2298
2306
|
display: block;
|
|
2299
2307
|
color: var(--ctp-yellow);
|
|
@@ -2314,6 +2322,7 @@ button.footer-meta {
|
|
|
2314
2322
|
.git-changes-empty.error {
|
|
2315
2323
|
color: var(--ctp-red);
|
|
2316
2324
|
}
|
|
2325
|
+
.git-changes-status.success,
|
|
2317
2326
|
.git-changes-empty.success {
|
|
2318
2327
|
color: var(--ctp-green);
|
|
2319
2328
|
}
|
|
@@ -5792,11 +5801,41 @@ button.composer-skill-tag:focus-visible {
|
|
|
5792
5801
|
gap: 0.6rem;
|
|
5793
5802
|
align-items: center;
|
|
5794
5803
|
}
|
|
5804
|
+
.app-runner-custom-item.unavailable {
|
|
5805
|
+
border-color: rgba(243, 139, 168, 0.32);
|
|
5806
|
+
}
|
|
5795
5807
|
.app-runner-custom-item-details {
|
|
5796
5808
|
display: grid;
|
|
5797
5809
|
gap: 0.18rem;
|
|
5798
5810
|
min-width: 0;
|
|
5799
5811
|
}
|
|
5812
|
+
.app-runner-custom-warning,
|
|
5813
|
+
.app-runner-custom-feedback {
|
|
5814
|
+
color: var(--danger);
|
|
5815
|
+
line-height: 1.4;
|
|
5816
|
+
font-size: 0.82rem;
|
|
5817
|
+
font-weight: 700;
|
|
5818
|
+
}
|
|
5819
|
+
.app-runner-custom-feedback {
|
|
5820
|
+
padding: 0.58rem 0.66rem;
|
|
5821
|
+
border: 1px solid rgba(243, 139, 168, 0.26);
|
|
5822
|
+
border-radius: 0.68rem;
|
|
5823
|
+
background: rgba(243, 139, 168, 0.08);
|
|
5824
|
+
}
|
|
5825
|
+
.app-runner-custom-feedback.success {
|
|
5826
|
+
color: var(--ctp-green);
|
|
5827
|
+
border-color: rgba(166, 227, 161, 0.28);
|
|
5828
|
+
background: rgba(166, 227, 161, 0.08);
|
|
5829
|
+
}
|
|
5830
|
+
.app-runner-custom-feedback.warning {
|
|
5831
|
+
color: var(--ctp-yellow);
|
|
5832
|
+
border-color: rgba(249, 226, 175, 0.28);
|
|
5833
|
+
background: rgba(249, 226, 175, 0.08);
|
|
5834
|
+
}
|
|
5835
|
+
.app-runner-custom-diagnostics {
|
|
5836
|
+
display: grid;
|
|
5837
|
+
gap: 0.42rem;
|
|
5838
|
+
}
|
|
5800
5839
|
.app-runner-custom-item-actions,
|
|
5801
5840
|
.app-runner-custom-form-actions {
|
|
5802
5841
|
display: flex;
|
|
@@ -219,6 +219,54 @@ try {
|
|
|
219
219
|
assert.equal(clampedMessages.body?.data?.since, 3, "since beyond the transcript should clamp to the total count");
|
|
220
220
|
assert.equal((clampedMessages.body?.data?.messages || []).length, 0);
|
|
221
221
|
|
|
222
|
+
// Custom app runners: save failures must be explicit, saved runners must be runnable,
|
|
223
|
+
// and stale saved runners must explain why they are not shown in the Run menu.
|
|
224
|
+
await writeFile(path.join(cwd, "custom-runner.mjs"), "console.log('custom runner ok')\n");
|
|
225
|
+
const missingCommandRunner = await request("127.0.0.1", "/api/app-runner-config", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
body: { tab: tabId, runner: { label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" } },
|
|
228
|
+
});
|
|
229
|
+
assert.equal(missingCommandRunner.status, 400, "saving a custom runner with a missing command should fail visibly");
|
|
230
|
+
assert.match(String(missingCommandRunner.body?.error || ""), /Command is not available: definitely-missing-pi-webui-runner/);
|
|
231
|
+
|
|
232
|
+
const savedCustomRunner = await request("127.0.0.1", "/api/app-runner-config", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
body: { tab: tabId, runner: { label: "Custom node", command: process.execPath, path: "custom-runner.mjs" } },
|
|
235
|
+
timeoutMs: 10_000,
|
|
236
|
+
});
|
|
237
|
+
assert.equal(savedCustomRunner.status, 200, `saving a valid custom runner should succeed: ${savedCustomRunner.body?.error || ""}`);
|
|
238
|
+
const customConfigRunner = savedCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Custom node");
|
|
239
|
+
assert.equal(customConfigRunner?.available, true, "saved custom runner config should mark runnable entries available");
|
|
240
|
+
const customRunner = savedCustomRunner.body?.data?.runners?.find((runner) => runner.custom === true && runner.label === "Custom node");
|
|
241
|
+
assert.ok(customRunner?.id, "saved available custom runner should appear in detected app runners");
|
|
242
|
+
|
|
243
|
+
const customRunStart = await request("127.0.0.1", "/api/app-runner", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body: { tab: tabId, runnerId: customRunner.id },
|
|
246
|
+
timeoutMs: 10_000,
|
|
247
|
+
});
|
|
248
|
+
assert.equal(customRunStart.status, 200, `custom runner start should return ok: ${customRunStart.body?.error || ""}`);
|
|
249
|
+
let customRunState = customRunStart;
|
|
250
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
251
|
+
if (customRunState.body?.data?.activeRun?.status && customRunState.body.data.activeRun.status !== "running") break;
|
|
252
|
+
await delay(100);
|
|
253
|
+
customRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
|
|
254
|
+
}
|
|
255
|
+
assert.equal(customRunState.body?.data?.activeRun?.status, "done", "custom runner should finish successfully");
|
|
256
|
+
assert.match((customRunState.body?.data?.activeRun?.lines || []).join("\n"), /custom runner ok/, "custom runner output should be captured");
|
|
257
|
+
await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
|
|
258
|
+
|
|
259
|
+
await writeFile(path.join(cwd, ".pi-webui-runners.json"), `${JSON.stringify({
|
|
260
|
+
version: 1,
|
|
261
|
+
runners: [{ id: "broken-custom", label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" }],
|
|
262
|
+
}, null, 2)}\n`);
|
|
263
|
+
const staleCustomRunner = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 10_000 });
|
|
264
|
+
assert.equal(staleCustomRunner.status, 200);
|
|
265
|
+
const brokenConfigRunner = staleCustomRunner.body?.data?.customRunnerConfig?.runners?.find((runner) => runner.label === "Broken custom");
|
|
266
|
+
assert.equal(brokenConfigRunner?.available, false, "unavailable saved custom runners should be flagged in config data");
|
|
267
|
+
assert.match(String(brokenConfigRunner?.unavailableReason || ""), /Command is not available: definitely-missing-pi-webui-runner/);
|
|
268
|
+
assert.equal(staleCustomRunner.body?.data?.runners?.some((runner) => runner.label === "Broken custom"), false, "unavailable custom runners should not appear in runnable menu data");
|
|
269
|
+
|
|
222
270
|
// Native slash command routed through the adapter (/copy → get_last_assistant_text).
|
|
223
271
|
const copy = await request("127.0.0.1", "/api/prompt", {
|
|
224
272
|
method: "POST",
|
|
@@ -346,16 +346,20 @@ assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable fo
|
|
|
346
346
|
assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
|
|
347
347
|
assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
|
|
348
348
|
assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
|
|
349
|
-
assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh controls and a diff body");
|
|
349
|
+
assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesPullButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh, pull controls, and a diff body");
|
|
350
350
|
assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
|
|
351
351
|
assert.match(app, /async function loadGitChangesDialog[\s\S]*api\("\/api\/git-changes"/, "git changes modal should load diff data from the server endpoint");
|
|
352
|
+
assert.match(app, /async function pullGitChangesDialog\(\)[\s\S]*api\("\/api\/git-changes\/pull", \{ method: "POST"/, "git changes modal should post to the pull endpoint from the Pull button");
|
|
353
|
+
assert.match(app, /function gitDiffDisplayLine\(row, side\)[\s\S]*`-\$\{text\}`[\s\S]*`\+\$\{text\}`/, "git changes modal should render changed lines with +/- prefixes");
|
|
352
354
|
assert.match(app, /function gitUntrackedEntryToDiffFile\(entry\)[\s\S]*?renderRowLimit:\s*Number\.POSITIVE_INFINITY[\s\S]*?type: "added"/, "untracked files should render as complete added-file diffs without the row preview cap");
|
|
353
355
|
assert.match(app, /async function loadMissingGitUntrackedContent\(entry[\s\S]*?\/api\/git-changes\/untracked-file\?path=/, "untracked path-only payloads should fetch complete file contents instead of rendering as empty files");
|
|
354
356
|
assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySelectorAll\("\.git-diff-file\[data-git-diff-file\]"\)/, "git changes modal should derive the sticky current-file header from visible file cards");
|
|
355
357
|
assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
|
|
356
358
|
assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
|
|
357
|
-
assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?\["diff", "--cached"/, "server should collect
|
|
359
|
+
assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
|
|
360
|
+
assert.match(server, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
|
|
358
361
|
assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
|
|
362
|
+
assert.match(server, /url\.pathname === "\/api\/git-changes\/pull" && req\.method === "POST"[\s\S]*?pullGitChanges\(tab\.cwd\)/, "server should expose POST /api/git-changes/pull for the changes modal Pull button");
|
|
359
363
|
assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.footer-line-meta \{[\s\S]*?display:\s*flex;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?\.footer-workspace,\n\s+\.footer-model,\n\s+\.footer-thinking \{ grid-column:\s*auto; \}/, "narrow git-footer metadata should wrap like the top metric row instead of forcing a two-column grid");
|
|
360
364
|
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.context-meter-bar \{ display:\s*none !important; \}/, "mobile should hide the WebUI context meter that appears after high context usage");
|
|
361
365
|
assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
|
|
@@ -670,6 +674,9 @@ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided
|
|
|
670
674
|
assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
|
|
671
675
|
assert.match(app, /async function refreshAppRunners\(tabContext = activeTabContext\(\)\)/, "frontend should load detected app runners for the active tab cwd");
|
|
672
676
|
assert.match(app, /function renderAppRunnerWidget\(\)/, "frontend should render app runner output in the shared top widget area");
|
|
677
|
+
assert.match(app, /function appRunnerFailureState\(runnerId, error[\s\S]*failed to start app runner/, "frontend should render visible app-runner start failures instead of only logging them");
|
|
678
|
+
assert.match(app, /appRunnerCustomFeedback[\s\S]*Custom app runner was not saved/, "custom app-runner save failures should be shown inline in the dialog");
|
|
679
|
+
assert.match(server, /function customAppRunnerUnavailableReason\(projectRoot, runner\)[\s\S]*Command is not available/, "server should explain why saved custom app runners are unavailable");
|
|
673
680
|
assert.match(server, /url\.pathname === "\/api\/app-runners" && req\.method === "GET"/, "server should expose detected app runners for the active tab cwd");
|
|
674
681
|
assert.match(server, /url\.pathname === "\/api\/app-runner" && req\.method === "POST"/, "server should start selected app runners directly");
|
|
675
682
|
assert.match(server, /function addGoRunner\(runners, cwd\)[\s\S]*Go\/Golang app entry/, "server should detect Go\/Golang app runners");
|